From 7b327215f3c05abc734c51c7f9bfb40b894a2ec0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:35:02 -0500 Subject: [PATCH] refactor: adaptive UI components for Navigation 3 (#4891) --- .github/copilot-instructions.md | 4 +- AGENTS.md | 4 +- GEMINI.md | 4 +- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 5 +- core/ui/build.gradle.kts | 4 + .../component/AdaptiveListDetailScaffold.kt | 113 +++++ .../core/ui/component/AdaptiveTwoPane.kt | 13 +- docs/kmp-status.md | 7 +- docs/roadmap.md | 9 +- .../ui/contact/AdaptiveContactsScreen.kt | 143 ++---- .../node/navigation/AdaptiveNodeListScreen.kt | 113 ++--- .../settings/navigation/SettingsMainScreen.kt | 24 - .../component/DeviceConfigScreen.android.kt | 55 +++ .../ExternalNotificationConfigItemList.kt | 329 ------------- ...xternalNotificationConfigScreen.android.kt | 95 ++++ .../radio/component/PositionConfigItemList.kt | 334 ------------- .../component/PositionConfigScreen.android.kt | 68 +++ .../component/SecurityConfigScreen.android.kt | 87 ++++ .../settings/navigation/SettingsNavigation.kt | 23 +- .../radio/component/DeviceConfigScreen.kt} | 53 +- .../ExternalNotificationConfigScreen.kt} | 61 ++- .../radio/component/PositionConfigScreen.kt} | 48 +- .../radio/component/SecurityConfigScreen.kt} | 56 +-- .../settings/navigation/SettingsNavigation.kt | 20 - .../radio/component/DeviceConfigScreen.ios.kt | 25 + .../ExternalNotificationConfigScreen.ios.kt | 24 + .../component/PositionConfigScreen.ios.kt | 30 ++ .../component/SecurityConfigScreen.ios.kt | 30 ++ .../settings/DesktopDeviceConfigScreen.kt | 461 ------------------ .../settings/DesktopSecurityConfigScreen.kt | 232 --------- .../settings/navigation/SettingsMainScreen.kt | 24 - .../radio/component/DeviceConfigScreen.jvm.kt | 145 ++++++ .../ExternalNotificationConfigScreen.jvm.kt | 25 + .../component/PositionConfigScreen.jvm.kt | 30 ++ .../component/SecurityConfigScreen.jvm.kt | 31 ++ 35 files changed, 978 insertions(+), 1751 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.android.kt delete mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt delete mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.android.kt create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt rename feature/settings/src/{androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt => commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt} (89%) rename feature/settings/src/{jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt => commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.kt} (86%) rename feature/settings/src/{jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt => commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt} (91%) rename feature/settings/src/{androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt => commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt} (83%) create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.ios.kt create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.ios.kt create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.ios.kt create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt delete mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt delete mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.jvm.kt create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.jvm.kt create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.jvm.kt create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b3547a58c..82f6c153a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,8 +18,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. - **UI:** Jetpack Compose Multiplatform (Material 3). - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`. - - **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state. + - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`. + - **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints. - **Database:** Room KMP. ## 2. Codebase Map @@ -63,6 +64,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable. - **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. - **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. +- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. ### B. Logic & Data Layer diff --git a/AGENTS.md b/AGENTS.md index b3547a58c..82f6c153a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. - **UI:** Jetpack Compose Multiplatform (Material 3). - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`. - - **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state. + - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`. + - **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints. - **Database:** Room KMP. ## 2. Codebase Map @@ -63,6 +64,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable. - **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. - **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. +- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. ### B. Logic & Data Layer diff --git a/GEMINI.md b/GEMINI.md index aae64c1a2..0e2d85567 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -18,8 +18,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. - **UI:** Jetpack Compose Multiplatform (Material 3). - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`. - - **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state. + - **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`. + - **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints. - **Database:** Room KMP. ## 2. Codebase Map @@ -63,6 +64,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable. - **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. - **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. +- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. ### B. Logic & Data Layer diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index e10fbbbd3..69eefcd30 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -182,7 +182,10 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie }, ) } - val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) + val navSuiteType = + NavigationSuiteScaffoldDefaults.navigationSuiteType( + currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true), + ) val currentKey = backStack.lastOrNull() val topLevelDestination = TopLevelDestination.fromNavKey(currentKey) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7ba2bdae3..005c857b6 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -51,6 +51,10 @@ kotlin { implementation(libs.kermit) implementation(libs.koin.compose.viewmodel) implementation(libs.qrcode.kotlin) + implementation(libs.jetbrains.compose.material3.adaptive) + implementation(libs.jetbrains.compose.material3.adaptive.layout) + implementation(libs.jetbrains.compose.material3.adaptive.navigation) + implementation(libs.jetbrains.navigationevent.compose) } val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt new file mode 100644 index 000000000..415937ccc --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalFocusManager +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun AdaptiveListDetailScaffold( + navigator: ThreePaneScaffoldNavigator, + scrollToTopEvents: Flow, + onBackToGraph: () -> Unit, + onTabPressedEvent: (ScrollToTopEvent) -> Boolean, + initialKey: T? = null, + listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit, + detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit, + emptyDetailPane: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange + + val handleBack: () -> Unit = { + if (navigator.canNavigateBack(backNavigationBehavior)) { + scope.launch { navigator.navigateBack(backNavigationBehavior) } + } else { + onBackToGraph() + } + } + + val navState = rememberNavigationEventState(NavigationEventInfo.None) + NavigationBackHandler( + state = navState, + isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail, + onBackCancelled = {}, + onBackCompleted = { handleBack() }, + ) + + LaunchedEffect(initialKey) { + if (initialKey != null) { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey) + } + } + + LaunchedEffect(scrollToTopEvents) { + scrollToTopEvents.collect { event -> + if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { + if (navigator.canNavigateBack(backNavigationBehavior)) { + navigator.navigateBack(backNavigationBehavior) + } else { + navigator.navigateTo(ListDetailPaneScaffoldRole.List) + } + } + } + } + + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + AnimatedPane { + val focusManager = LocalFocusManager.current + // Prevent TextFields from auto-focusing when pane animates in + LaunchedEffect(Unit) { focusManager.clearFocus() } + + listPane( + navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List, + navigator.currentDestination?.contentKey, + ) + } + }, + detailPane = { + AnimatedPane { + val focusManager = LocalFocusManager.current + + navigator.currentDestination?.contentKey?.let { contentKey -> + key(contentKey) { + LaunchedEffect(contentKey) { focusManager.clearFocus() } + detailPane(contentKey, handleBack) + } + } ?: emptyDetailPane() + } + }, + ) +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt index d8d969ac9..51bb294b2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt @@ -20,14 +20,20 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass @Composable -fun AdaptiveTwoPane(first: @Composable ColumnScope.() -> Unit, second: @Composable ColumnScope.() -> Unit) = +fun AdaptiveTwoPane(first: @Composable ColumnScope.() -> Unit, second: @Composable ColumnScope.() -> Unit) { + val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true) + + // In V2 Breakpoints, we check the breakpoint explicitly. Medium corresponds to 600dp+. + val compactWidth = + !adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + BoxWithConstraints { - val compactWidth = maxWidth < 600.dp Row { Column(modifier = Modifier.weight(1f)) { first() @@ -42,3 +48,4 @@ fun AdaptiveTwoPane(first: @Composable ColumnScope.() -> Unit, second: @Composab } } } +} diff --git a/docs/kmp-status.md b/docs/kmp-status.md index ebaa3be9a..05c0b49ed 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -72,7 +72,7 @@ Working Compose Desktop application with: | Area | Score | Notes | |---|---|---| | Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified | -| Shared feature/UI logic | **9/10** | All 7 KMP; feature:connections unified; cross-platform deduplication complete | +| Shared feature/UI logic | **9.5/10** | All 7 KMP; feature:connections unified; Navigation 3 Stable Scene-based architecture adopted; cross-platform deduplication complete | | Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) | | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated | @@ -107,18 +107,19 @@ Based on the latest codebase investigation, the following steps are proposed to | Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared enum + parity tests. See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) | | Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) | | BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) | -| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-alpha04` | +| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-alpha04`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | | JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime | | Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained | | Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` | | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | -| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()` to `commonMain`; eliminated ~200 lines of duplicated code across Android/desktop | +| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `AdaptiveListDetailScaffold`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | ## Navigation Parity Note - Desktop and Android both use the shared `TopLevelDestination` enum from `core:navigation/commonMain` — no separate `DesktopDestination` remains. +- Both shells utilize the stable **Navigation 3 Scene-based architecture**, allowing for multi-pane layouts (e.g., three-pane on Large/XL displays) using shared routes. - Both shells iterate `TopLevelDestination.entries` with shared icon mapping from `core:ui` (`TopLevelDestinationExt.icon`). - Desktop locale changes now trigger a full subtree recomposition from `Main.kt` without resetting the shared Navigation 3 backstack, so translated labels update in place. - Firmware remains available as an in-flow route instead of a top-level destination, matching Android information architecture. diff --git a/docs/roadmap.md b/docs/roadmap.md index 40737b0f7..41e1eb593 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -43,6 +43,7 @@ These items address structural gaps identified in the March 2026 architecture re 2. **Tier 2: Polish (High Priority)** - Additional desktop-specific settings polish - ✅ **Keyboard shortcuts** via `onPreviewKeyEvent` (MenuBar removed) + - **Adaptive density & multitasking optimizations** (2026 Desktop Guidelines) - Window management - State persistence 3. **Tier 3: Advanced (Nice-to-have)** @@ -64,7 +65,7 @@ These items address structural gaps identified in the March 2026 architecture re | Feature | Status | |---|---| -| Settings | ✅ ~35 real screens (fully shared) + desktop locale picker with in-place recomposition | +| Settings | ✅ ~35 real screens (fully shared); `DeviceConfig`, `PositionConfig`, `SecurityConfig`, `ExternalNotificationConfig` fully unified into `commonMain` | | Node list | ✅ Adaptive list-detail with real `NodeDetailContent` | | Messaging | ✅ Adaptive contacts with real message view + send | | Connections | ✅ Unified shared UI with dynamic transport detection | @@ -91,8 +92,10 @@ These items address structural gaps identified in the March 2026 architecture re ## Medium-Term Priorities (60 days) 1. **iOS proof target** — ✅ **Done (Stubbing):** Stubbed iOS target implementations (`NoopStubs.kt` equivalent) to successfully pass compile-time checks. **Next:** Setup an Xcode skeleton project and launch the iOS app. -2. **`core:api` contract split** — separate transport-neutral service contracts from the Android AIDL packaging to support iOS/Desktop service layers. -3. **Decouple Firmware DFU** — `feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it to allow the core `feature:firmware` module to be utilized on desktop/iOS. +2. **Migrate to Navigation 3 Scene-based architecture** — leverage the first stable release of Nav 3 to support multi-pane layouts. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) on Large (1200dp) and Extra-large (1600dp) displays (Android 16 QPR3). +3. **`core:api` contract split** — separate transport-neutral service contracts from the Android AIDL packaging to support iOS/Desktop service layers. +4. **Decouple Firmware DFU** — `feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it to allow the core `feature:firmware` module to be utilized on desktop/iOS. +5. ✅ **Adopt `WindowSizeClass.BREAKPOINTS_V2`** — Done: Updated `AdaptiveTwoPane.kt` and `Main.kt` components to call `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)`. ## Longer-Term (90+ days) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index 00814a3a8..07184c60b 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -17,21 +17,12 @@ package org.meshtastic.feature.messaging.ui.contact import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import androidx.navigationevent.NavigationEventInfo -import androidx.navigationevent.NavigationEventTransitionState -import androidx.navigationevent.compose.NavigationBackHandler -import androidx.navigationevent.compose.rememberNavigationEventState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -41,6 +32,7 @@ import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.conversations +import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold import org.meshtastic.core.ui.component.EmptyDetailPlaceholder import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.icon.Conversations @@ -69,9 +61,8 @@ fun AdaptiveContactsScreen( ) { val navigator = rememberListDetailPaneScaffoldNavigator() val scope = rememberCoroutineScope() - val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange - val handleBack: () -> Unit = { + val onBackToGraph: () -> Unit = { val currentKey = backStack.lastOrNull() if ( @@ -90,97 +81,53 @@ fun AdaptiveContactsScreen( if (isFromDifferentGraph) { // Navigate back via NavController to return to the previous screen (e.g. Node Details) backStack.removeLastOrNull() + } + } + } + + AdaptiveListDetailScaffold( + navigator = navigator, + scrollToTopEvents = scrollToTopEvents, + onBackToGraph = onBackToGraph, + onTabPressedEvent = { it is ScrollToTopEvent.ConversationsTabPressed }, + initialKey = initialContactKey, + listPane = { isActive, activeContactKey -> + ContactsScreen( + onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) }, + sharedContactRequested = sharedContactRequested, + requestChannelSet = requestChannelSet, + onHandleScannedUri = onHandleScannedUri, + onClearSharedContactRequested = onClearSharedContactRequested, + onClearRequestChannelUrl = onClearRequestChannelUrl, + viewModel = contactsViewModel, + onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, + onNavigateToMessages = { contactKey -> + scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) } + }, + onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, + scrollToTopEvents = scrollToTopEvents, + activeContactKey = activeContactKey, + ) + }, + detailPane = { contentKey, handleBack -> + if (detailPaneCustom != null) { + detailPaneCustom(contentKey) } else { - // Close the detail pane within the adaptive scaffold - scope.launch { navigator.navigateBack(backNavigationBehavior) } - } - } else { - scope.launch { navigator.navigateBack(backNavigationBehavior) } - } - } - - val navState = rememberNavigationEventState(NavigationEventInfo.None) - NavigationBackHandler( - state = navState, - isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail, - onBackCancelled = { /* Gesture cancelled */ }, - onBackCompleted = { handleBack() }, - ) - LaunchedEffect(navState.transitionState) { - val transitionState = navState.transitionState - if (transitionState is NavigationEventTransitionState.InProgress) { - val progress = transitionState.latestEvent.progress - // Animate the back gesture progress could be used here to drive UI if scaffold supported it - } - } - - LaunchedEffect(initialContactKey) { - if (initialContactKey != null) { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey) - } - } - - LaunchedEffect(scrollToTopEvents) { - scrollToTopEvents.collect { event -> - if ( - event is ScrollToTopEvent.ConversationsTabPressed && - navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail - ) { - if (navigator.canNavigateBack(backNavigationBehavior)) { - navigator.navigateBack(backNavigationBehavior) - } else { - navigator.navigateTo(ListDetailPaneScaffoldRole.List) - } - } - } - } - - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - listPane = { - AnimatedPane { - ContactsScreen( - onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) }, - sharedContactRequested = sharedContactRequested, - requestChannelSet = requestChannelSet, - onHandleScannedUri = onHandleScannedUri, - onClearSharedContactRequested = onClearSharedContactRequested, - onClearRequestChannelUrl = onClearRequestChannelUrl, - viewModel = contactsViewModel, - onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, - onNavigateToMessages = { contactKey -> - scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) } - }, - onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, - scrollToTopEvents = scrollToTopEvents, - activeContactKey = navigator.currentDestination?.contentKey, + MessageScreen( + contactKey = contentKey, + message = if (contentKey == initialContactKey) initialMessage else "", + viewModel = messageViewModel, + navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, + navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) }, + onNavigateBack = handleBack, ) } }, - detailPane = { - AnimatedPane { - navigator.currentDestination?.contentKey?.let { contactKey -> - key(contactKey) { - if (detailPaneCustom != null) { - detailPaneCustom(contactKey) - } else { - MessageScreen( - contactKey = contactKey, - message = if (contactKey == initialContactKey) initialMessage else "", - viewModel = messageViewModel, - navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, - navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) }, - onNavigateBack = handleBack, - ) - } - } - } - ?: EmptyDetailPlaceholder( - icon = MeshtasticIcons.Conversations, - title = stringResource(Res.string.conversations), - ) - } + emptyDetailPane = { + EmptyDetailPlaceholder( + icon = MeshtasticIcons.Conversations, + title = stringResource(Res.string.conversations), + ) }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index 249d31b99..9f3bba39a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -17,21 +17,12 @@ package org.meshtastic.feature.node.navigation import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalFocusManager import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import androidx.navigationevent.NavigationEventInfo -import androidx.navigationevent.compose.NavigationBackHandler -import androidx.navigationevent.compose.rememberNavigationEventState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -41,6 +32,7 @@ import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.nodes +import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold import org.meshtastic.core.ui.component.EmptyDetailPlaceholder import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.icon.MeshtasticIcons @@ -64,9 +56,8 @@ fun AdaptiveNodeListScreen( val nodeListViewModel: NodeListViewModel = koinViewModel() val navigator = rememberListDetailPaneScaffoldNavigator() val scope = rememberCoroutineScope() - val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange - val handleBack: () -> Unit = { + val onBackToGraph: () -> Unit = { val currentKey = backStack.lastOrNull() val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null @@ -76,80 +67,40 @@ fun AdaptiveNodeListScreen( if (isFromDifferentGraph && !isNodesRoute) { // Navigate back via NavController to return to the previous screen backStack.removeLastOrNull() - } else { - // Close the detail pane within the adaptive scaffold - scope.launch { navigator.navigateBack(backNavigationBehavior) } } } - val navState = rememberNavigationEventState(NavigationEventInfo.None) - NavigationBackHandler( - state = navState, - isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail, - onBackCancelled = {}, - onBackCompleted = { handleBack() }, - ) - - LaunchedEffect(initialNodeId) { - if (initialNodeId != null) { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialNodeId) - } - } - - LaunchedEffect(scrollToTopEvents) { - scrollToTopEvents.collect { event -> - if ( - event is ScrollToTopEvent.NodesTabPressed && - navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail - ) { - if (navigator.canNavigateBack(backNavigationBehavior)) { - navigator.navigateBack(backNavigationBehavior) - } else { - navigator.navigateTo(ListDetailPaneScaffoldRole.List) - } - } - } - } - - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - listPane = { - AnimatedPane { - val focusManager = LocalFocusManager.current - // Prevent TextFields from auto-focusing when pane animates in - LaunchedEffect(Unit) { focusManager.clearFocus() } - NodeListScreen( - viewModel = nodeListViewModel, - navigateToNodeDetails = { nodeId -> - scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) } - }, - onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, - scrollToTopEvents = scrollToTopEvents, - activeNodeId = navigator.currentDestination?.contentKey, - ) - } + AdaptiveListDetailScaffold( + navigator = navigator, + scrollToTopEvents = scrollToTopEvents, + onBackToGraph = onBackToGraph, + onTabPressedEvent = { it is ScrollToTopEvent.NodesTabPressed }, + initialKey = initialNodeId, + listPane = { isActive, activeNodeId -> + NodeListScreen( + viewModel = nodeListViewModel, + navigateToNodeDetails = { nodeId -> + scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) } + }, + onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, + scrollToTopEvents = scrollToTopEvents, + activeNodeId = activeNodeId, + ) }, - detailPane = { - AnimatedPane { - val focusManager = LocalFocusManager.current - // Prevent TextFields from auto-focusing when pane animates in - navigator.currentDestination?.contentKey?.let { nodeId -> - key(nodeId) { - LaunchedEffect(nodeId) { focusManager.clearFocus() } - val nodeDetailViewModel: NodeDetailViewModel = koinViewModel() - val compassViewModel: CompassViewModel = koinViewModel() - NodeDetailScreen( - nodeId = nodeId, - viewModel = nodeDetailViewModel, - compassViewModel = compassViewModel, - navigateToMessages = onNavigateToMessages, - onNavigate = onNavigate, - onNavigateUp = handleBack, - ) - } - } ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes)) - } + detailPane = { contentKey, handleBack -> + val nodeDetailViewModel: NodeDetailViewModel = koinViewModel() + val compassViewModel: CompassViewModel = koinViewModel() + NodeDetailScreen( + nodeId = contentKey, + viewModel = nodeDetailViewModel, + compassViewModel = compassViewModel, + navigateToMessages = onNavigateToMessages, + onNavigate = onNavigate, + onNavigateUp = handleBack, + ) + }, + emptyDetailPane = { + EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes)) }, ) } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt index 6ae709459..773664c1f 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -21,10 +21,6 @@ import org.meshtastic.core.navigation.Route import org.meshtastic.feature.settings.SettingsScreen import org.meshtastic.feature.settings.SettingsViewModel import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.DeviceConfigScreen as AndroidDeviceConfigScreen -import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen as AndroidExternalNotificationConfigScreen -import org.meshtastic.feature.settings.radio.component.PositionConfigScreen as AndroidPositionConfigScreen -import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen as AndroidSecurityConfigScreen @Composable actual fun SettingsMainScreen( @@ -40,23 +36,3 @@ actual fun SettingsMainScreen( onNavigate = onNavigate, ) } - -@Composable -actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - AndroidDeviceConfigScreen(viewModel = viewModel, onBack = onBack) -} - -@Composable -actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - AndroidPositionConfigScreen(viewModel = viewModel, onBack = onBack) -} - -@Composable -actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - AndroidSecurityConfigScreen(viewModel = viewModel, onBack = onBack) -} - -@Composable -actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - AndroidExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack) -} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.android.kt new file mode 100644 index 000000000..67b117494 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.android.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import org.meshtastic.core.model.util.toPosixString +import java.time.ZoneId + +@Composable +actual fun rememberSystemTimeZonePosixString(): String { + val context = LocalContext.current + var appTzPosixString by remember { mutableStateOf(ZoneId.systemDefault().toPosixString()) } + + DisposableEffect(context) { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + appTzPosixString = ZoneId.systemDefault().toPosixString() + } + } + androidx.core.content.ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), + androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED, + ) + onDispose { context.unregisterReceiver(receiver) } + } + + return appTzPosixString +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt deleted file mode 100644 index a90fc3cd7..000000000 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.radio.component - -import android.media.MediaPlayer -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.advanced -import org.meshtastic.core.resources.alert_bell_buzzer -import org.meshtastic.core.resources.alert_bell_led -import org.meshtastic.core.resources.alert_bell_vibra -import org.meshtastic.core.resources.alert_message_buzzer -import org.meshtastic.core.resources.alert_message_led -import org.meshtastic.core.resources.alert_message_vibra -import org.meshtastic.core.resources.external_notification -import org.meshtastic.core.resources.external_notification_config -import org.meshtastic.core.resources.external_notification_enabled -import org.meshtastic.core.resources.import_label -import org.meshtastic.core.resources.nag_timeout_seconds -import org.meshtastic.core.resources.notifications_on_alert_bell_receipt -import org.meshtastic.core.resources.notifications_on_message_receipt -import org.meshtastic.core.resources.output_buzzer_gpio -import org.meshtastic.core.resources.output_duration_milliseconds -import org.meshtastic.core.resources.output_led_active_high -import org.meshtastic.core.resources.output_led_gpio -import org.meshtastic.core.resources.output_vibra_gpio -import org.meshtastic.core.resources.play -import org.meshtastic.core.resources.ringtone -import org.meshtastic.core.resources.use_i2s_as_buzzer -import org.meshtastic.core.resources.use_pwm_buzzer -import org.meshtastic.core.ui.component.DropDownPreference -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.core.ui.component.TitledCard -import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.util.IntervalConfiguration -import org.meshtastic.feature.settings.util.gpioPins -import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.ModuleConfig -import java.io.File - -private const val MAX_RINGTONE_SIZE = 230 - -@Suppress("LongMethod", "TooGenericExceptionCaught") -@Composable -fun ExternalNotificationConfigScreen( - onBack: () -> Unit, - modifier: Modifier = Modifier, - viewModel: RadioConfigViewModel, -) { - val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig() - val ringtone = state.ringtone - val formState = rememberConfigState(initialValue = extNotificationConfig) - var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) } - val focusManager = LocalFocusManager.current - val context = LocalContext.current - - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri?.let { - try { - context.contentResolver.openInputStream(it)?.use { stream -> - stream.bufferedReader().use { reader -> - val buffer = CharArray(MAX_RINGTONE_SIZE) - val read = reader.read(buffer) - if (read > 0) { - ringtoneInput = String(buffer, 0, read) - Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show() - } - } - } - } catch (e: Exception) { - Logger.e(e) { "Error importing ringtone" } - Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show() - } - } - } - - RadioConfigScreenList( - modifier = modifier, - title = stringResource(Res.string.external_notification), - onBack = onBack, - configState = formState, - enabled = state.connected, - responseState = state.responseState, - onDismissPacketResponse = viewModel::clearPacketResponse, - additionalDirtyCheck = { ringtoneInput != ringtone }, - onDiscard = { ringtoneInput = ringtone }, - onSave = { - if (ringtoneInput != ringtone) { - viewModel.setRingtone(ringtoneInput) - } - if (formState.value != extNotificationConfig) { - val config = ModuleConfig(external_notification = formState.value) - viewModel.setModuleConfig(config) - } - }, - ) { - item { - TitledCard(title = stringResource(Res.string.external_notification_config)) { - SwitchPreference( - title = stringResource(Res.string.external_notification_enabled), - checked = formState.value.enabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - - item { - TitledCard(title = stringResource(Res.string.notifications_on_message_receipt)) { - SwitchPreference( - title = stringResource(Res.string.alert_message_led), - checked = formState.value.alert_message, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(alert_message = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.alert_message_buzzer), - checked = formState.value.alert_message_buzzer, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(alert_message_buzzer = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.alert_message_vibra), - checked = formState.value.alert_message_vibra, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(alert_message_vibra = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - - item { - TitledCard(title = stringResource(Res.string.notifications_on_alert_bell_receipt)) { - SwitchPreference( - title = stringResource(Res.string.alert_bell_led), - checked = formState.value.alert_bell, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(alert_bell = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.alert_bell_buzzer), - checked = formState.value.alert_bell_buzzer, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(alert_bell_buzzer = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.alert_bell_vibra), - checked = formState.value.alert_bell_vibra, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(alert_bell_vibra = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - - item { - TitledCard(title = stringResource(Res.string.advanced)) { - val gpio = remember { gpioPins } - DropDownPreference( - title = stringResource(Res.string.output_led_gpio), - items = gpio, - selectedItem = formState.value.output.toLong(), - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(output = it.toInt()) }, - ) - if (formState.value.output != 0) { - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.output_led_active_high), - checked = formState.value.active, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(active = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.output_buzzer_gpio), - items = gpio, - selectedItem = formState.value.output_buzzer.toLong(), - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(output_buzzer = it.toInt()) }, - ) - if (formState.value.output_buzzer != 0) { - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.use_pwm_buzzer), - checked = formState.value.use_pwm, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(use_pwm = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.output_vibra_gpio), - items = gpio, - selectedItem = formState.value.output_vibra.toLong(), - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(output_vibra = it.toInt()) }, - ) - HorizontalDivider() - val outputItems = remember { IntervalConfiguration.OUTPUT.allowedIntervals } - DropDownPreference( - title = stringResource(Res.string.output_duration_milliseconds), - items = outputItems.map { it.value to it.toDisplayString() }, - selectedItem = formState.value.output_ms.toLong(), - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(output_ms = it.toInt()) }, - ) - HorizontalDivider() - val nagItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals } - DropDownPreference( - title = stringResource(Res.string.nag_timeout_seconds), - items = nagItems.map { it.value to it.toDisplayString() }, - selectedItem = formState.value.nag_timeout.toLong(), - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(nag_timeout = it.toInt()) }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.ringtone), - value = ringtoneInput, - maxSize = MAX_RINGTONE_SIZE, - enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { ringtoneInput = it }, - trailingIcon = { - Row { - IconButton(onClick = { launcher.launch("*/*") }, enabled = state.connected) { - Icon( - Icons.Default.FolderOpen, - contentDescription = stringResource(Res.string.import_label), - ) - } - - IconButton( - onClick = { - try { - val tempFile = File.createTempFile("ringtone", ".rtttl", context.cacheDir) - tempFile.writeText(ringtoneInput) - val mediaPlayer = MediaPlayer() - mediaPlayer.setDataSource(tempFile.absolutePath) - mediaPlayer.prepare() - mediaPlayer.start() - mediaPlayer.setOnCompletionListener { - it.release() - tempFile.delete() - } - } catch (e: Exception) { - Logger.e(e) { "Failed to play ringtone" } - } - }, - enabled = state.connected, - ) { - Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.play)) - } - } - }, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.use_i2s_as_buzzer), - checked = formState.value.use_i2s_as_buzzer, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(use_i2s_as_buzzer = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - } -} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt new file mode 100644 index 000000000..611837422 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import android.media.MediaPlayer +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.import_label +import org.meshtastic.core.resources.play +import java.io.File + +private const val MAX_RINGTONE_SIZE = 230 + +@Suppress("TooGenericExceptionCaught") +@Composable +actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { + val context = LocalContext.current + + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + try { + context.contentResolver.openInputStream(it)?.use { stream -> + stream.bufferedReader().use { reader -> + val buffer = CharArray(MAX_RINGTONE_SIZE) + val read = reader.read(buffer) + if (read > 0) { + onRingtoneImported(String(buffer, 0, read)) + Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show() + } + } + } + } catch (e: Exception) { + Logger.e(e) { "Error importing ringtone" } + Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + Row { + IconButton(onClick = { launcher.launch("*/*") }, enabled = enabled) { + Icon(Icons.Default.FolderOpen, contentDescription = stringResource(Res.string.import_label)) + } + + IconButton( + onClick = { + try { + val tempFile = File.createTempFile("ringtone", ".rtttl", context.cacheDir) + tempFile.writeText(ringtoneInput) + val mediaPlayer = MediaPlayer() + mediaPlayer.setDataSource(tempFile.absolutePath) + mediaPlayer.prepare() + mediaPlayer.start() + mediaPlayer.setOnCompletionListener { + it.release() + tempFile.delete() + } + } catch (e: Exception) { + Logger.e(e) { "Failed to play ringtone" } + } + }, + enabled = enabled, + ) { + Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.play)) + } + } +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt deleted file mode 100644 index 4b84d3106..000000000 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.radio.component - -import android.annotation.SuppressLint -import android.location.Location -import android.os.Build -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalFocusManager -import androidx.core.location.LocationCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.Position -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.advanced_device_gps -import org.meshtastic.core.resources.altitude -import org.meshtastic.core.resources.broadcast_interval -import org.meshtastic.core.resources.config_position_broadcast_secs_summary -import org.meshtastic.core.resources.config_position_broadcast_smart_minimum_distance_summary -import org.meshtastic.core.resources.config_position_broadcast_smart_minimum_interval_secs_summary -import org.meshtastic.core.resources.config_position_flags_summary -import org.meshtastic.core.resources.config_position_gps_update_interval_summary -import org.meshtastic.core.resources.device_gps -import org.meshtastic.core.resources.fixed_position -import org.meshtastic.core.resources.gps_en_gpio -import org.meshtastic.core.resources.gps_mode -import org.meshtastic.core.resources.gps_receive_gpio -import org.meshtastic.core.resources.gps_transmit_gpio -import org.meshtastic.core.resources.latitude -import org.meshtastic.core.resources.longitude -import org.meshtastic.core.resources.minimum_distance -import org.meshtastic.core.resources.minimum_interval -import org.meshtastic.core.resources.position -import org.meshtastic.core.resources.position_config_set_fixed_from_phone -import org.meshtastic.core.resources.position_flags -import org.meshtastic.core.resources.position_packet -import org.meshtastic.core.resources.smart_position -import org.meshtastic.core.resources.update_interval -import org.meshtastic.core.ui.component.BitwisePreference -import org.meshtastic.core.ui.component.DropDownPreference -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.core.ui.component.TitledCard -import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.util.FixedUpdateIntervals -import org.meshtastic.feature.settings.util.IntervalConfiguration -import org.meshtastic.feature.settings.util.gpioPins -import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.Config - -@Composable -@Suppress("LongMethod", "CyclomaticComplexMethod") -fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val coroutineScope = rememberCoroutineScope() - var phoneLocation: Location? by remember { mutableStateOf(null) } - val node by viewModel.destNode.collectAsStateWithLifecycle() - val currentPosition = - Position( - latitude = node?.latitude ?: 0.0, - longitude = node?.longitude ?: 0.0, - altitude = node?.position?.altitude ?: 0, - time = 1, // ignore time for fixed_position - ) - val positionConfig = state.radioConfig.position ?: Config.PositionConfig() - val sanitizedPositionConfig = - remember(positionConfig) { - val positionItems = IntervalConfiguration.POSITION.allowedIntervals - val smartBroadcastItems = IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals - var updated = positionConfig - if (FixedUpdateIntervals.fromValue(updated.position_broadcast_secs.toLong()) == null) { - updated = updated.copy(position_broadcast_secs = positionItems.first().value.toInt()) - } - if (FixedUpdateIntervals.fromValue(updated.broadcast_smart_minimum_interval_secs.toLong()) == null) { - updated = - updated.copy(broadcast_smart_minimum_interval_secs = smartBroadcastItems.first().value.toInt()) - } - if (FixedUpdateIntervals.fromValue(updated.gps_update_interval.toLong()) == null) { - updated = updated.copy(gps_update_interval = positionItems.first().value.toInt()) - } - updated - } - val formState = rememberConfigState(initialValue = sanitizedPositionConfig) - var locationInput by rememberSaveable { mutableStateOf(currentPosition) } - - LaunchedEffect(phoneLocation) { - phoneLocation?.let { phoneLoc -> - locationInput = - Position( - latitude = phoneLoc.latitude, - longitude = phoneLoc.longitude, - altitude = - LocationCompat.hasMslAltitude(phoneLoc).let { - if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - phoneLoc.mslAltitudeMeters.toInt() - } else { - phoneLoc.altitude.toInt() - } - }, - ) - } - } - val focusManager = LocalFocusManager.current - RadioConfigScreenList( - title = stringResource(Res.string.position), - onBack = onBack, - configState = formState, - enabled = state.connected, - responseState = state.responseState, - onDismissPacketResponse = viewModel::clearPacketResponse, - additionalDirtyCheck = { locationInput != currentPosition }, - onDiscard = { locationInput = currentPosition }, - onSave = { - if (formState.value.fixed_position) { - if (locationInput != currentPosition) { - viewModel.setFixedPosition(locationInput) - } - } else { - if (positionConfig.fixed_position) { - // fixed position changed from enabled to disabled - viewModel.removeFixedPosition() - } - } - val config = Config(position = it) - viewModel.setConfig(config) - }, - ) { - item { - TitledCard(title = stringResource(Res.string.position_packet)) { - val items = remember { IntervalConfiguration.POSITION_BROADCAST.allowedIntervals } - DropDownPreference( - title = stringResource(Res.string.broadcast_interval), - summary = stringResource(Res.string.config_position_broadcast_secs_summary), - enabled = state.connected, - items = items.map { it to it.toDisplayString() }, - selectedItem = - FixedUpdateIntervals.fromValue(formState.value.position_broadcast_secs.toLong()) - ?: items.first(), - onItemSelected = { - formState.value = formState.value.copy(position_broadcast_secs = it.value.toInt()) - }, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.smart_position), - checked = formState.value.position_broadcast_smart_enabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(position_broadcast_smart_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - if (formState.value.position_broadcast_smart_enabled) { - HorizontalDivider() - val smartItems = remember { IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals } - DropDownPreference( - title = stringResource(Res.string.minimum_interval), - summary = - stringResource(Res.string.config_position_broadcast_smart_minimum_interval_secs_summary), - enabled = state.connected, - items = smartItems.map { it to it.toDisplayString() }, - selectedItem = - FixedUpdateIntervals.fromValue( - formState.value.broadcast_smart_minimum_interval_secs.toLong(), - ) ?: smartItems.first(), - onItemSelected = { - formState.value = - formState.value.copy(broadcast_smart_minimum_interval_secs = it.value.toInt()) - }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.minimum_distance), - summary = stringResource(Res.string.config_position_broadcast_smart_minimum_distance_summary), - value = formState.value.broadcast_smart_minimum_distance, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - formState.value = formState.value.copy(broadcast_smart_minimum_distance = it) - }, - ) - } - } - } - item { - TitledCard(title = stringResource(Res.string.device_gps)) { - SwitchPreference( - title = stringResource(Res.string.fixed_position), - checked = formState.value.fixed_position, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(fixed_position = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - if (formState.value.fixed_position) { - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.latitude), - value = locationInput.latitude, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { lat: Double -> - if (lat >= -90 && lat <= 90.0) { - locationInput = locationInput.copy(latitude = lat) - } - }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.longitude), - value = locationInput.longitude, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { lon: Double -> - if (lon >= -180 && lon <= 180.0) { - locationInput = locationInput.copy(longitude = lon) - } - }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.altitude), - value = locationInput.altitude, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { alt: Int -> locationInput = locationInput.copy(altitude = alt) }, - ) - HorizontalDivider() - // RequireLocation wrapper removed to complete Nordic removal. - // Should be replaced with a generic solution later. - TextButton( - enabled = state.connected, - onClick = { - @SuppressLint("MissingPermission") - coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() as? Location } - }, - ) { - Text(text = stringResource(Res.string.position_config_set_fixed_from_phone)) - } - } else { - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.gps_mode), - enabled = state.connected, - items = Config.PositionConfig.GpsMode.entries.map { it to it.name }, - selectedItem = formState.value.gps_mode, - onItemSelected = { formState.value = formState.value.copy(gps_mode = it) }, - ) - HorizontalDivider() - val items = remember { IntervalConfiguration.GPS_UPDATE.allowedIntervals } - DropDownPreference( - title = stringResource(Res.string.update_interval), - summary = stringResource(Res.string.config_position_gps_update_interval_summary), - enabled = state.connected, - items = items.map { it to it.toDisplayString() }, - selectedItem = - FixedUpdateIntervals.fromValue(formState.value.gps_update_interval.toLong()) - ?: items.first(), - onItemSelected = { - formState.value = formState.value.copy(gps_update_interval = it.value.toInt()) - }, - ) - } - } - } - item { - TitledCard(title = stringResource(Res.string.position_flags)) { - BitwisePreference( - title = stringResource(Res.string.position_flags), - summary = stringResource(Res.string.config_position_flags_summary), - value = formState.value.position_flags, - enabled = state.connected, - items = - Config.PositionConfig.PositionFlags.entries - .filter { it != Config.PositionConfig.PositionFlags.UNSET } - .map { it.value to it.name }, - onItemSelected = { formState.value = formState.value.copy(position_flags = it) }, - ) - } - } - item { - TitledCard(title = stringResource(Res.string.advanced_device_gps)) { - val pins = remember { gpioPins } - DropDownPreference( - title = stringResource(Res.string.gps_receive_gpio), - enabled = state.connected, - items = pins, - selectedItem = formState.value.rx_gpio, - onItemSelected = { formState.value = formState.value.copy(rx_gpio = it) }, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.gps_transmit_gpio), - enabled = state.connected, - items = pins, - selectedItem = formState.value.tx_gpio, - onItemSelected = { formState.value = formState.value.copy(tx_gpio = it) }, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.gps_en_gpio), - enabled = state.connected, - items = pins, - selectedItem = formState.value.gps_en_gpio, - onItemSelected = { formState.value = formState.value.copy(gps_en_gpio = it) }, - ) - } - } - } -} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.android.kt new file mode 100644 index 000000000..256a21d69 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.android.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import android.annotation.SuppressLint +import android.os.Build +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.location.LocationCompat +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Position +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.position_config_set_fixed_from_phone +import org.meshtastic.feature.settings.radio.RadioConfigViewModel + +@Composable +actual fun DeviceLocationButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + onLocationReceived: (Position) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + TextButton( + enabled = enabled, + onClick = { + @SuppressLint("MissingPermission") + coroutineScope.launch { + val phoneLoc = viewModel.getCurrentLocation() + if (phoneLoc != null) { + val locationInput = + Position( + latitude = phoneLoc.latitude, + longitude = phoneLoc.longitude, + altitude = + LocationCompat.hasMslAltitude(phoneLoc).let { + if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + phoneLoc.mslAltitudeMeters.toInt() + } else { + phoneLoc.altitude.toInt() + } + }, + ) + onLocationReceived(locationInput) + } + } + }, + ) { + Text(text = stringResource(Res.string.position_config_set_fixed_from_phone)) + } +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt new file mode 100644 index 000000000..82ad76554 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.toMeshtasticUri +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.export_keys +import org.meshtastic.core.resources.export_keys_confirmation +import org.meshtastic.core.ui.component.MeshtasticResourceDialog +import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.proto.Config + +@Composable +actual fun ExportSecurityConfigButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + securityConfig: Config.SecurityConfig, +) { + val node by viewModel.destNode.collectAsStateWithLifecycle() + var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) } + + val exportConfigLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) } + } + } + + if (showEditSecurityConfigDialog) { + MeshtasticResourceDialog( + titleRes = Res.string.export_keys, + messageRes = Res.string.export_keys_confirmation, + onDismiss = { showEditSecurityConfigDialog = false }, + onConfirm = { + showEditSecurityConfigDialog = false + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/*" + putExtra(Intent.EXTRA_TITLE, "${node?.user?.short_name}_keys_$nowMillis.json") + } + exportConfigLauncher.launch(intent) + }, + ) + } + + HorizontalDivider() + NodeActionButton( + modifier = Modifier.padding(horizontal = 8.dp), + title = stringResource(Res.string.export_keys), + enabled = enabled, + icon = Icons.TwoTone.Warning, + onClick = { showEditSecurityConfigDialog = true }, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index c909e38e8..edf6caeb7 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -45,15 +45,19 @@ import org.meshtastic.feature.settings.radio.component.AudioConfigScreen import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen +import org.meshtastic.feature.settings.radio.component.DeviceConfigScreenCommon import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen +import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreenCommon import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen +import org.meshtastic.feature.settings.radio.component.PositionConfigScreenCommon import org.meshtastic.feature.settings.radio.component.PowerConfigScreen import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen +import org.meshtastic.feature.settings.radio.component.SecurityConfigScreenCommon import org.meshtastic.feature.settings.radio.component.SerialConfigScreen import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen @@ -131,14 +135,14 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { when (routeInfo) { ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DEVICE -> DeviceConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POSITION -> PositionConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.DEVICE -> DeviceConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.POSITION -> PositionConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.SECURITY -> SecurityConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.SECURITY -> SecurityConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) } } } @@ -150,7 +154,10 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.EXT_NOTIFICATION -> - ExternalNotificationConfigScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() }) + ExternalNotificationConfigScreenCommon( + viewModel = viewModel, + onBack = { backStack.removeLastOrNull() }, + ) ModuleRoute.STORE_FORWARD -> StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) @@ -201,14 +208,6 @@ expect fun SettingsMainScreen( ) /** Expect declarations for platform-specific config screens. */ -@Composable expect fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) - -@Composable expect fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) - -@Composable expect fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) - -@Composable expect fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) - fun EntryProviderScope.configComposable( route: KClass, backStack: NavBackStack, diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt similarity index 89% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt index 36adae131..cf7b0ef2b 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt @@ -16,10 +16,6 @@ */ package org.meshtastic.feature.settings.radio.component -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,7 +36,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,19 +45,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.util.toPosixString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.accept import org.meshtastic.core.resources.are_you_sure @@ -106,6 +97,7 @@ import org.meshtastic.core.resources.role_tracker_desc import org.meshtastic.core.resources.router_role_confirmation_text import org.meshtastic.core.resources.time_zone import org.meshtastic.core.resources.triple_click_adhoc_ping +import org.meshtastic.core.resources.unrecognized import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.InsetDivider @@ -113,11 +105,13 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.role +import org.meshtastic.core.ui.util.annotatedStringFromHtml import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString import org.meshtastic.proto.Config -import java.time.ZoneId + +@Composable expect fun rememberSystemTimeZonePosixString(): String @Suppress("DEPRECATION") private val Config.DeviceConfig.Role.description: StringResource @@ -136,6 +130,7 @@ private val Config.DeviceConfig.Role.description: StringResource Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc + else -> Res.string.unrecognized } private val Config.DeviceConfig.RebroadcastMode.description: StringResource @@ -148,11 +143,12 @@ private val Config.DeviceConfig.RebroadcastMode.description: StringResource Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> Res.string.rebroadcast_mode_core_portnums_only_desc + else -> Res.string.unrecognized } @Suppress("DEPRECATION", "LongMethod") @Composable -fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { +fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig() val formState = rememberConfigState(initialValue = deviceConfig) @@ -184,7 +180,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) { item { TitledCard(title = stringResource(Res.string.options)) { - val currentRole = formState.value.role + val currentRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT DropDownPreference( title = stringResource(Res.string.role), enabled = state.connected, @@ -197,7 +193,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { HorizontalDivider() - val currentRebroadcastMode = formState.value.rebroadcast_mode + val currentRebroadcastMode = formState.value.rebroadcast_mode ?: Config.DeviceConfig.RebroadcastMode.ALL DropDownPreference( title = stringResource(Res.string.rebroadcast_mode), enabled = state.connected, @@ -211,7 +207,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals } DropDownPreference( title = stringResource(Res.string.nodeinfo_broadcast_interval), - selectedItem = formState.value.node_info_broadcast_secs.toLong(), + selectedItem = (formState.value.node_info_broadcast_secs ?: 0).toLong(), enabled = state.connected, items = nodeInfoBroadcastIntervals.map { it.value to it.toDisplayString() }, onItemSelected = { formState.value = formState.value.copy(node_info_broadcast_secs = it.toInt()) }, @@ -255,28 +251,11 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } item { TitledCard(title = stringResource(Res.string.time_zone)) { - val context = LocalContext.current - var appTzPosixString by remember { mutableStateOf(ZoneId.systemDefault().toPosixString()) } - - DisposableEffect(context) { - val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - appTzPosixString = ZoneId.systemDefault().toPosixString() - } - } - androidx.core.content.ContextCompat.registerReceiver( - context, - receiver, - IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), - androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED, - ) - onDispose { context.unregisterReceiver(receiver) } - } + val appTzPosixString = rememberSystemTimeZonePosixString() EditTextPreference( title = "", - value = formState.value.tzdef, + value = formState.value.tzdef ?: "", summary = stringResource(Res.string.config_device_tzdef_summary), maxSize = 64, // tzdef max_size:65 enabled = state.connected, @@ -313,7 +292,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { TitledCard(title = stringResource(Res.string.gpio)) { EditTextPreference( title = stringResource(Res.string.button_gpio), - value = formState.value.button_gpio, + value = formState.value.button_gpio ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { formState.value = formState.value.copy(button_gpio = it) }, @@ -323,7 +302,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { EditTextPreference( title = stringResource(Res.string.buzzer_gpio), - value = formState.value.buzzer_gpio, + value = formState.value.buzzer_gpio ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { formState.value = formState.value.copy(buzzer_gpio = it) }, @@ -337,8 +316,8 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { fun RouterRoleConfirmationDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { val dialogTitle = stringResource(Res.string.are_you_sure) val annotatedDialogText = - AnnotatedString.fromHtml( - htmlString = stringResource(Res.string.router_role_confirmation_text), + annotatedStringFromHtml( + html = stringResource(Res.string.router_role_confirmation_text), linkStyles = TextLinkStyles(style = SpanStyle(color = Color.Blue)), ) diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.kt similarity index 86% rename from feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.kt index b19ecdb21..a05dcf389 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings +package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -58,18 +59,22 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList -import org.meshtastic.feature.settings.radio.component.rememberConfigState import org.meshtastic.feature.settings.util.IntervalConfiguration -import org.meshtastic.feature.settings.util.gpioPins import org.meshtastic.feature.settings.util.toDisplayString import org.meshtastic.proto.ModuleConfig private const val MAX_RINGTONE_SIZE = 230 @Composable -@Suppress("LongMethod", "CyclomaticComplexMethod") -fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { +expect fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) + +@Suppress("LongMethod", "TooGenericExceptionCaught") +@Composable +fun ExternalNotificationConfigScreenCommon( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: RadioConfigViewModel, +) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig() val ringtone = state.ringtone @@ -78,6 +83,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB val focusManager = LocalFocusManager.current RadioConfigScreenList( + modifier = modifier, title = stringResource(Res.string.external_notification), onBack = onBack, configState = formState, @@ -100,7 +106,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB TitledCard(title = stringResource(Res.string.external_notification_config)) { SwitchPreference( title = stringResource(Res.string.external_notification_enabled), - checked = formState.value.enabled ?: false, + checked = formState.value.enabled, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -112,7 +118,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB TitledCard(title = stringResource(Res.string.notifications_on_message_receipt)) { SwitchPreference( title = stringResource(Res.string.alert_message_led), - checked = formState.value.alert_message ?: false, + checked = formState.value.alert_message, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(alert_message = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -120,7 +126,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_message_buzzer), - checked = formState.value.alert_message_buzzer ?: false, + checked = formState.value.alert_message_buzzer, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(alert_message_buzzer = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -128,7 +134,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_message_vibra), - checked = formState.value.alert_message_vibra ?: false, + checked = formState.value.alert_message_vibra, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(alert_message_vibra = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -140,7 +146,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB TitledCard(title = stringResource(Res.string.notifications_on_alert_bell_receipt)) { SwitchPreference( title = stringResource(Res.string.alert_bell_led), - checked = formState.value.alert_bell ?: false, + checked = formState.value.alert_bell, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(alert_bell = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -148,7 +154,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_bell_buzzer), - checked = formState.value.alert_bell_buzzer ?: false, + checked = formState.value.alert_bell_buzzer, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(alert_bell_buzzer = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -156,7 +162,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_bell_vibra), - checked = formState.value.alert_bell_vibra ?: false, + checked = formState.value.alert_bell_vibra, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(alert_bell_vibra = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -166,19 +172,19 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB item { TitledCard(title = stringResource(Res.string.advanced)) { - val gpio = remember { gpioPins } + val gpio = remember { org.meshtastic.feature.settings.util.gpioPins } DropDownPreference( title = stringResource(Res.string.output_led_gpio), items = gpio, - selectedItem = (formState.value.output ?: 0).toLong(), + selectedItem = formState.value.output.toLong(), enabled = state.connected, onItemSelected = { formState.value = formState.value.copy(output = it.toInt()) }, ) - if (formState.value.output ?: 0 != 0) { + if (formState.value.output != 0) { HorizontalDivider() SwitchPreference( title = stringResource(Res.string.output_led_active_high), - checked = formState.value.active ?: false, + checked = formState.value.active, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(active = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -188,15 +194,15 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB DropDownPreference( title = stringResource(Res.string.output_buzzer_gpio), items = gpio, - selectedItem = (formState.value.output_buzzer ?: 0).toLong(), + selectedItem = formState.value.output_buzzer.toLong(), enabled = state.connected, onItemSelected = { formState.value = formState.value.copy(output_buzzer = it.toInt()) }, ) - if (formState.value.output_buzzer ?: 0 != 0) { + if (formState.value.output_buzzer != 0) { HorizontalDivider() SwitchPreference( title = stringResource(Res.string.use_pwm_buzzer), - checked = formState.value.use_pwm ?: false, + checked = formState.value.use_pwm, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(use_pwm = it) }, containerColor = CardDefaults.cardColors().containerColor, @@ -206,7 +212,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB DropDownPreference( title = stringResource(Res.string.output_vibra_gpio), items = gpio, - selectedItem = (formState.value.output_vibra ?: 0).toLong(), + selectedItem = formState.value.output_vibra.toLong(), enabled = state.connected, onItemSelected = { formState.value = formState.value.copy(output_vibra = it.toInt()) }, ) @@ -215,7 +221,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB DropDownPreference( title = stringResource(Res.string.output_duration_milliseconds), items = outputItems.map { it.value to it.toDisplayString() }, - selectedItem = (formState.value.output_ms ?: 0).toLong(), + selectedItem = formState.value.output_ms.toLong(), enabled = state.connected, onItemSelected = { formState.value = formState.value.copy(output_ms = it.toInt()) }, ) @@ -224,7 +230,7 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB DropDownPreference( title = stringResource(Res.string.nag_timeout_seconds), items = nagItems.map { it.value to it.toDisplayString() }, - selectedItem = (formState.value.nag_timeout ?: 0).toLong(), + selectedItem = formState.value.nag_timeout.toLong(), enabled = state.connected, onItemSelected = { formState.value = formState.value.copy(nag_timeout = it.toInt()) }, ) @@ -239,11 +245,18 @@ fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onB KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { ringtoneInput = it }, + trailingIcon = { + RingtoneTrailingIcon( + ringtoneInput = ringtoneInput, + onRingtoneImported = { ringtoneInput = it }, + enabled = state.connected, + ) + }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.use_i2s_as_buzzer), - checked = formState.value.use_i2s_as_buzzer ?: false, + checked = formState.value.use_i2s_as_buzzer, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(use_i2s_as_buzzer = it) }, containerColor = CardDefaults.cardColors().containerColor, diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt similarity index 91% rename from feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt index b137daa91..309c7dffb 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings +package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.CardDefaults @@ -59,17 +59,21 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList -import org.meshtastic.feature.settings.radio.component.rememberConfigState import org.meshtastic.feature.settings.util.FixedUpdateIntervals import org.meshtastic.feature.settings.util.IntervalConfiguration -import org.meshtastic.feature.settings.util.gpioPins import org.meshtastic.feature.settings.util.toDisplayString import org.meshtastic.proto.Config +@Composable +expect fun DeviceLocationButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + onLocationReceived: (Position) -> Unit, +) + @Composable @Suppress("LongMethod", "CyclomaticComplexMethod") -fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { +fun PositionConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val node by viewModel.destNode.collectAsStateWithLifecycle() val currentPosition = @@ -134,7 +138,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U enabled = state.connected, items = items.map { it to it.toDisplayString() }, selectedItem = - FixedUpdateIntervals.fromValue((formState.value.position_broadcast_secs ?: 0).toLong()) + FixedUpdateIntervals.fromValue(formState.value.position_broadcast_secs.toLong()) ?: items.first(), onItemSelected = { formState.value = formState.value.copy(position_broadcast_secs = it.value.toInt()) @@ -143,12 +147,12 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U HorizontalDivider() SwitchPreference( title = stringResource(Res.string.smart_position), - checked = formState.value.position_broadcast_smart_enabled ?: false, + checked = formState.value.position_broadcast_smart_enabled, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(position_broadcast_smart_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) - if (formState.value.position_broadcast_smart_enabled ?: false) { + if (formState.value.position_broadcast_smart_enabled) { HorizontalDivider() val smartItems = remember { IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals } DropDownPreference( @@ -159,7 +163,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U items = smartItems.map { it to it.toDisplayString() }, selectedItem = FixedUpdateIntervals.fromValue( - (formState.value.broadcast_smart_minimum_interval_secs ?: 0).toLong(), + formState.value.broadcast_smart_minimum_interval_secs.toLong(), ) ?: smartItems.first(), onItemSelected = { formState.value = @@ -170,7 +174,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U EditTextPreference( title = stringResource(Res.string.minimum_distance), summary = stringResource(Res.string.config_position_broadcast_smart_minimum_distance_summary), - value = formState.value.broadcast_smart_minimum_distance ?: 0, + value = formState.value.broadcast_smart_minimum_distance, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { @@ -184,12 +188,12 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U TitledCard(title = stringResource(Res.string.device_gps)) { SwitchPreference( title = stringResource(Res.string.fixed_position), - checked = formState.value.fixed_position ?: false, + checked = formState.value.fixed_position, enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(fixed_position = it) }, containerColor = CardDefaults.cardColors().containerColor, ) - if (formState.value.fixed_position ?: false) { + if (formState.value.fixed_position) { HorizontalDivider() EditTextPreference( title = stringResource(Res.string.latitude), @@ -222,13 +226,19 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { alt: Int -> locationInput = locationInput.copy(altitude = alt) }, ) + HorizontalDivider() + DeviceLocationButton( + viewModel = viewModel, + enabled = state.connected, + onLocationReceived = { locationInput = it }, + ) } else { HorizontalDivider() DropDownPreference( title = stringResource(Res.string.gps_mode), enabled = state.connected, items = Config.PositionConfig.GpsMode.entries.map { it to it.name }, - selectedItem = formState.value.gps_mode ?: Config.PositionConfig.GpsMode.DISABLED, + selectedItem = formState.value.gps_mode, onItemSelected = { formState.value = formState.value.copy(gps_mode = it) }, ) HorizontalDivider() @@ -239,7 +249,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U enabled = state.connected, items = items.map { it to it.toDisplayString() }, selectedItem = - FixedUpdateIntervals.fromValue((formState.value.gps_update_interval ?: 0).toLong()) + FixedUpdateIntervals.fromValue(formState.value.gps_update_interval.toLong()) ?: items.first(), onItemSelected = { formState.value = formState.value.copy(gps_update_interval = it.value.toInt()) @@ -253,7 +263,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U BitwisePreference( title = stringResource(Res.string.position_flags), summary = stringResource(Res.string.config_position_flags_summary), - value = formState.value.position_flags ?: 0, + value = formState.value.position_flags, enabled = state.connected, items = Config.PositionConfig.PositionFlags.entries @@ -265,12 +275,12 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U } item { TitledCard(title = stringResource(Res.string.advanced_device_gps)) { - val pins = remember { gpioPins } + val pins = remember { org.meshtastic.feature.settings.util.gpioPins } DropDownPreference( title = stringResource(Res.string.gps_receive_gpio), enabled = state.connected, items = pins, - selectedItem = formState.value.rx_gpio ?: 0, + selectedItem = formState.value.rx_gpio, onItemSelected = { formState.value = formState.value.copy(rx_gpio = it) }, ) HorizontalDivider() @@ -278,7 +288,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U title = stringResource(Res.string.gps_transmit_gpio), enabled = state.connected, items = pins, - selectedItem = formState.value.tx_gpio ?: 0, + selectedItem = formState.value.tx_gpio, onItemSelected = { formState.value = formState.value.copy(tx_gpio = it) }, ) HorizontalDivider() @@ -286,7 +296,7 @@ fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U title = stringResource(Res.string.gps_en_gpio), enabled = state.connected, items = pins, - selectedItem = formState.value.gps_en_gpio ?: 0, + selectedItem = formState.value.gps_en_gpio, onItemSelected = { formState.value = formState.value.copy(gps_en_gpio = it) }, ) } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt similarity index 83% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 440166010..94e25df9b 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -16,10 +16,6 @@ */ package org.meshtastic.feature.settings.radio.component -import android.app.Activity -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons @@ -39,8 +35,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import okio.ByteString import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.model.util.encodeToString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.admin_key @@ -54,8 +48,6 @@ import org.meshtastic.core.resources.config_security_public_key import org.meshtastic.core.resources.config_security_serial_enabled import org.meshtastic.core.resources.debug_log_api_enabled import org.meshtastic.core.resources.direct_message_key -import org.meshtastic.core.resources.export_keys -import org.meshtastic.core.resources.export_keys_confirmation import org.meshtastic.core.resources.legacy_admin_channel import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.managed_mode @@ -73,13 +65,19 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config -import java.security.SecureRandom +import kotlin.random.Random + +@Composable +expect fun ExportSecurityConfigButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + securityConfig: Config.SecurityConfig, +) @Composable @Suppress("LongMethod") -fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { +fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val node by viewModel.destNode.collectAsStateWithLifecycle() val securityConfig = state.radioConfig.security ?: Config.SecurityConfig() val formState = rememberConfigState(initialValue = securityConfig) @@ -92,13 +90,6 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } } - val exportConfigLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) } - } - } - var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) } PrivateKeyRegenerateDialog( showKeyGenerationDialog = showKeyGenerationDialog, @@ -110,24 +101,6 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { }, onDismiss = { showKeyGenerationDialog = false }, ) - var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) } - if (showEditSecurityConfigDialog) { - MeshtasticResourceDialog( - titleRes = Res.string.export_keys, - messageRes = Res.string.export_keys_confirmation, - onDismiss = { showEditSecurityConfigDialog = false }, - onConfirm = { - showEditSecurityConfigDialog = false - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/*" - putExtra(Intent.EXTRA_TITLE, "${node?.user?.short_name}_keys_$nowMillis.json") - } - exportConfigLauncher.launch(intent) - }, - ) - } val focusManager = LocalFocusManager.current RadioConfigScreenList( @@ -180,13 +153,10 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { icon = Icons.TwoTone.Warning, onClick = { showKeyGenerationDialog = true }, ) - HorizontalDivider() - NodeActionButton( - modifier = Modifier.padding(horizontal = 8.dp), - title = stringResource(Res.string.export_keys), + ExportSecurityConfigButton( + viewModel = viewModel, enabled = state.connected, - icon = Icons.TwoTone.Warning, - onClick = { showEditSecurityConfigDialog = true }, + securityConfig = securityConfig, ) } } @@ -261,7 +231,7 @@ fun PrivateKeyRegenerateDialog( messageRes = Res.string.regenerate_keys_confirmation, onConfirm = { // Generate a random "f" value - val f = ByteArray(32).apply { SecureRandom().nextBytes(this) } + val f = ByteArray(32).apply { Random.nextBytes(this) } // Adjust the value to make it valid as an "s" value for eval(). // According to the specification we need to mask off the 3 // right-most bits of f[0], mask off the left-most bit of f[31], diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 9cb260a87..75f37c06e 100644 --- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -30,23 +30,3 @@ actual fun SettingsMainScreen( ) { // TODO: Implement iOS settings main screen } - -@Composable -actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - // TODO: Implement iOS device config screen -} - -@Composable -actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - // TODO: Implement iOS position config screen -} - -@Composable -actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - // TODO: Implement iOS security config screen -} - -@Composable -actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - // TODO: Implement iOS external notification config screen -} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.ios.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.ios.kt new file mode 100644 index 000000000..ccdd885dc --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.ios.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberSystemTimeZonePosixString(): String { + // TODO: Implementing proper iOS Posix string extraction will be required later. + return "GMT0" +} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.ios.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.ios.kt new file mode 100644 index 000000000..e83841c28 --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.ios.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable + +@Composable +actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { + // No-op for iOS for now +} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.ios.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.ios.kt new file mode 100644 index 000000000..989564601 --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.ios.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable +import org.meshtastic.core.model.Position +import org.meshtastic.feature.settings.radio.RadioConfigViewModel + +@Composable +actual fun DeviceLocationButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + onLocationReceived: (Position) -> Unit, +) { + // No-op for iOS for now +} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt new file mode 100644 index 000000000..05da3fec8 --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable +import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.proto.Config + +@Composable +actual fun ExportSecurityConfigButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + securityConfig: Config.SecurityConfig, +) { + // No-op for iOS for now +} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt deleted file mode 100644 index d17371701..000000000 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt +++ /dev/null @@ -1,461 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.PhoneAndroid -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.accept -import org.meshtastic.core.resources.are_you_sure -import org.meshtastic.core.resources.button_gpio -import org.meshtastic.core.resources.buzzer_gpio -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary -import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary -import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary -import org.meshtastic.core.resources.config_device_tzdef_summary -import org.meshtastic.core.resources.config_device_use_phone_tz -import org.meshtastic.core.resources.device -import org.meshtastic.core.resources.double_tap_as_button_press -import org.meshtastic.core.resources.gpio -import org.meshtastic.core.resources.hardware -import org.meshtastic.core.resources.i_know_what_i_m_doing -import org.meshtastic.core.resources.led_heartbeat -import org.meshtastic.core.resources.nodeinfo_broadcast_interval -import org.meshtastic.core.resources.options -import org.meshtastic.core.resources.rebroadcast_mode -import org.meshtastic.core.resources.rebroadcast_mode_all_desc -import org.meshtastic.core.resources.rebroadcast_mode_all_skip_decoding_desc -import org.meshtastic.core.resources.rebroadcast_mode_core_portnums_only_desc -import org.meshtastic.core.resources.rebroadcast_mode_known_only_desc -import org.meshtastic.core.resources.rebroadcast_mode_local_only_desc -import org.meshtastic.core.resources.rebroadcast_mode_none_desc -import org.meshtastic.core.resources.role -import org.meshtastic.core.resources.role_client_base_desc -import org.meshtastic.core.resources.role_client_desc -import org.meshtastic.core.resources.role_client_hidden_desc -import org.meshtastic.core.resources.role_client_mute_desc -import org.meshtastic.core.resources.role_lost_and_found_desc -import org.meshtastic.core.resources.role_repeater_desc -import org.meshtastic.core.resources.role_router_client_desc -import org.meshtastic.core.resources.role_router_desc -import org.meshtastic.core.resources.role_router_late_desc -import org.meshtastic.core.resources.role_sensor_desc -import org.meshtastic.core.resources.role_tak_desc -import org.meshtastic.core.resources.role_tak_tracker_desc -import org.meshtastic.core.resources.role_tracker_desc -import org.meshtastic.core.resources.router_role_confirmation_text -import org.meshtastic.core.resources.time_zone -import org.meshtastic.core.resources.triple_click_adhoc_ping -import org.meshtastic.core.resources.unrecognized -import org.meshtastic.core.ui.component.DropDownPreference -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.component.InsetDivider -import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.core.ui.component.TitledCard -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.role -import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList -import org.meshtastic.feature.settings.radio.component.rememberConfigState -import org.meshtastic.feature.settings.util.IntervalConfiguration -import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.Config -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.time.zone.ZoneOffsetTransitionRule -import java.util.Locale -import kotlin.math.abs - -private val Config.DeviceConfig.Role.description: StringResource - get() = - when (this) { - Config.DeviceConfig.Role.CLIENT -> Res.string.role_client_desc - Config.DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc - Config.DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc - Config.DeviceConfig.Role.ROUTER -> Res.string.role_router_desc - Config.DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc - Config.DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc - Config.DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc - Config.DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc - Config.DeviceConfig.Role.TAK -> Res.string.role_tak_desc - Config.DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc - Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc - Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc - Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc - else -> Res.string.unrecognized - } - -private val Config.DeviceConfig.RebroadcastMode.description: StringResource - get() = - when (this) { - Config.DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc - Config.DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc - Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc - Config.DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc - Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc - Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> - Res.string.rebroadcast_mode_core_portnums_only_desc - else -> Res.string.unrecognized - } - -@Composable -@Suppress("LongMethod") -fun DesktopDeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig() - val formState = rememberConfigState(initialValue = deviceConfig) - var selectedRole by rememberSaveable { mutableStateOf(formState.value.role ?: Config.DeviceConfig.Role.CLIENT) } - val infrastructureRoles = - listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER) - if (selectedRole != formState.value.role) { - if (selectedRole in infrastructureRoles) { - DesktopRouterRoleConfirmationDialog( - onDismiss = { selectedRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT }, - onConfirm = { formState.value = formState.value.copy(role = selectedRole) }, - ) - } else { - formState.value = formState.value.copy(role = selectedRole) - } - } - val focusManager = LocalFocusManager.current - RadioConfigScreenList( - title = stringResource(Res.string.device), - onBack = onBack, - configState = formState, - enabled = state.connected, - responseState = state.responseState, - onDismissPacketResponse = viewModel::clearPacketResponse, - onSave = { - val config = Config(device = it) - viewModel.setConfig(config) - }, - ) { - item { - TitledCard(title = stringResource(Res.string.options)) { - val currentRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT - DropDownPreference( - title = stringResource(Res.string.role), - enabled = state.connected, - selectedItem = currentRole, - onItemSelected = { selectedRole = it }, - summary = stringResource(currentRole.description), - itemIcon = { MeshtasticIcons.role(it) }, - itemLabel = { it.name }, - ) - - HorizontalDivider() - - val currentRebroadcastMode = formState.value.rebroadcast_mode ?: Config.DeviceConfig.RebroadcastMode.ALL - DropDownPreference( - title = stringResource(Res.string.rebroadcast_mode), - enabled = state.connected, - selectedItem = currentRebroadcastMode, - onItemSelected = { formState.value = formState.value.copy(rebroadcast_mode = it) }, - summary = stringResource(currentRebroadcastMode.description), - ) - - HorizontalDivider() - - val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals } - DropDownPreference( - title = stringResource(Res.string.nodeinfo_broadcast_interval), - selectedItem = (formState.value.node_info_broadcast_secs ?: 0).toLong(), - enabled = state.connected, - items = nodeInfoBroadcastIntervals.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy(node_info_broadcast_secs = it.toInt()) }, - ) - } - } - - item { - TitledCard(title = stringResource(Res.string.hardware)) { - SwitchPreference( - title = stringResource(Res.string.double_tap_as_button_press), - summary = stringResource(Res.string.config_device_doubleTapAsButtonPress_summary), - checked = formState.value.double_tap_as_button_press, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(double_tap_as_button_press = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - - InsetDivider() - - SwitchPreference( - title = stringResource(Res.string.triple_click_adhoc_ping), - summary = stringResource(Res.string.config_device_tripleClickAsAdHocPing_summary), - checked = !formState.value.disable_triple_click, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(disable_triple_click = !it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - - InsetDivider() - - SwitchPreference( - title = stringResource(Res.string.led_heartbeat), - summary = stringResource(Res.string.config_device_ledHeartbeatEnabled_summary), - checked = !formState.value.led_heartbeat_disabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(led_heartbeat_disabled = !it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - item { - TitledCard(title = stringResource(Res.string.time_zone)) { - val systemTzPosixString = remember { ZoneId.systemDefault().toPosixString() } - - EditTextPreference( - title = "", - value = formState.value.tzdef ?: "", - summary = stringResource(Res.string.config_device_tzdef_summary), - maxSize = 64, // tzdef max_size:65 - enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(tzdef = it) }, - trailingIcon = { - IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) { - Icon(imageVector = Icons.Rounded.Clear, contentDescription = null) - } - }, - ) - - HorizontalDivider() - - TextButton( - modifier = Modifier.height(40.dp).fillMaxWidth(), - enabled = state.connected, - shape = RectangleShape, - onClick = { formState.value = formState.value.copy(tzdef = systemTzPosixString) }, - ) { - Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null) - - Spacer(modifier = Modifier.width(8.dp)) - - Text(text = stringResource(Res.string.config_device_use_phone_tz)) - } - } - } - - item { - TitledCard(title = stringResource(Res.string.gpio)) { - EditTextPreference( - title = stringResource(Res.string.button_gpio), - value = formState.value.button_gpio ?: 0, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(button_gpio = it) }, - ) - - HorizontalDivider() - - EditTextPreference( - title = stringResource(Res.string.buzzer_gpio), - value = formState.value.buzzer_gpio ?: 0, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(buzzer_gpio = it) }, - ) - } - } - } -} - -@Composable -private fun DesktopRouterRoleConfirmationDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { - val dialogTitle = stringResource(Res.string.are_you_sure) - val dialogText = stringResource(Res.string.router_role_confirmation_text) - - var confirmed by rememberSaveable { mutableStateOf(false) } - - AlertDialog( - title = { Text(text = dialogTitle) }, - text = { - Column { - Text(text = dialogText) - Row( - modifier = Modifier.fillMaxWidth().clickable(true) { confirmed = !confirmed }, - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = confirmed, onCheckedChange = { confirmed = it }) - Text(stringResource(Res.string.i_know_what_i_m_doing)) - } - } - }, - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = onConfirm, enabled = confirmed) { Text(stringResource(Res.string.accept)) } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, - ) -} - -/** Generates a POSIX time zone string from a [ZoneId]. JVM/Desktop version of the Android-only `core:model` utility. */ -@Suppress("MagicNumber", "ReturnCount") -private fun ZoneId.toPosixString(): String { - val rules = this.rules - - if (rules.isFixedOffset || rules.transitionRules.isEmpty()) { - val now = java.time.Instant.now() - val zdt = ZonedDateTime.ofInstant(now, this) - return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}" - } - - val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds } - val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds } - - if (springRule == null || fallRule == null) { - val now = java.time.Instant.now() - val zdt = ZonedDateTime.ofInstant(now, this) - return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}" - } - - return buildString { - val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule) - val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule) - - append(formatAbbreviation(stdAbbrev)) - append(formatPosixOffset(springRule.offsetBefore)) - append(formatAbbreviation(dstAbbrev)) - - if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) { - append(formatPosixOffset(springRule.offsetAfter)) - } - - append(formatTransitionRule(springRule)) - append(formatTransitionRule(fallRule)) - } -} - -private fun ZonedDateTime.timeZoneShortName(): String { - val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH) - val shortName = format(formatter) - return if (shortName.startsWith("GMT")) "GMT" else shortName -} - -private fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>" - -private fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String { - val year = java.time.LocalDate.now().year - val transition = rule.createTransition(year) - return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName() -} - -@Suppress("MagicNumber") -private fun formatPosixOffset(offset: ZoneOffset): String { - val offsetSeconds = -offset.totalSeconds - val hours = offsetSeconds / 3600 - val remainingSeconds = abs(offsetSeconds) % 3600 - val minutes = remainingSeconds / 60 - val seconds = remainingSeconds % 60 - - return buildString { - if (offsetSeconds < 0 && hours == 0) append("-") - append(hours) - if (minutes != 0 || seconds != 0) { - append(":%02d".format(Locale.ENGLISH, minutes)) - if (seconds != 0) { - append(":%02d".format(Locale.ENGLISH, seconds)) - } - } - } -} - -@Suppress("MagicNumber") -private fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String { - val month = rule.month.value - val dayOfWeek = rule.dayOfWeek.value % 7 - val dayIndicator = rule.dayOfMonthIndicator - - val occurrence = - when { - dayIndicator < 0 -> 5 - dayIndicator > rule.month.length(false) - 7 -> 5 - else -> ((dayIndicator - 1) / 7) + 1 - } - - val wallTime = - when (rule.timeDefinition) { - ZoneOffsetTransitionRule.TimeDefinition.UTC -> - rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong()) - - ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> { - if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) { - rule.localTime - } else { - rule.localTime.plusSeconds( - (rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(), - ) - } - } - - else -> rule.localTime - } - - return buildString { - append(",M$month.$occurrence.$dayOfWeek") - if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) { - append("/${wallTime.hour}") - if (wallTime.minute != 0 || wallTime.second != 0) { - append(":%02d".format(Locale.ENGLISH, wallTime.minute)) - if (wallTime.second != 0) { - append(":%02d".format(Locale.ENGLISH, wallTime.second)) - } - } - } - } -} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt deleted file mode 100644 index fd3418063..000000000 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Warning -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import okio.ByteString -import okio.ByteString.Companion.toByteString -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.util.encodeToString -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.admin_key -import org.meshtastic.core.resources.admin_keys -import org.meshtastic.core.resources.administration -import org.meshtastic.core.resources.config_security_admin_key -import org.meshtastic.core.resources.config_security_debug_log_api_enabled -import org.meshtastic.core.resources.config_security_is_managed -import org.meshtastic.core.resources.config_security_private_key -import org.meshtastic.core.resources.config_security_public_key -import org.meshtastic.core.resources.config_security_serial_enabled -import org.meshtastic.core.resources.debug_log_api_enabled -import org.meshtastic.core.resources.direct_message_key -import org.meshtastic.core.resources.legacy_admin_channel -import org.meshtastic.core.resources.logs -import org.meshtastic.core.resources.managed_mode -import org.meshtastic.core.resources.private_key -import org.meshtastic.core.resources.public_key -import org.meshtastic.core.resources.regenerate_keys_confirmation -import org.meshtastic.core.resources.regenerate_private_key -import org.meshtastic.core.resources.security -import org.meshtastic.core.resources.serial_console -import org.meshtastic.core.ui.component.CopyIconButton -import org.meshtastic.core.ui.component.EditBase64Preference -import org.meshtastic.core.ui.component.EditListPreference -import org.meshtastic.core.ui.component.MeshtasticResourceDialog -import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.core.ui.component.TitledCard -import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.NodeActionButton -import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList -import org.meshtastic.feature.settings.radio.component.rememberConfigState -import org.meshtastic.proto.Config -import java.security.SecureRandom - -@Composable -@Suppress("LongMethod") -fun DesktopSecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val securityConfig = state.radioConfig.security ?: Config.SecurityConfig() - val formState = rememberConfigState(initialValue = securityConfig) - - var publicKey by rememberSaveable { mutableStateOf(formState.value.public_key) } - LaunchedEffect(formState.value.private_key) { - if (formState.value.private_key != securityConfig.private_key) { - publicKey = ByteString.EMPTY - } else if (formState.value.private_key == securityConfig.private_key) { - publicKey = securityConfig.public_key - } - } - - var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) } - if (showKeyGenerationDialog) { - DesktopPrivateKeyRegenerateDialog( - onConfirm = { - formState.value = it - showKeyGenerationDialog = false - val config = Config(security = formState.value) - viewModel.setConfig(config) - }, - onDismiss = { showKeyGenerationDialog = false }, - ) - } - - val focusManager = LocalFocusManager.current - RadioConfigScreenList( - title = stringResource(Res.string.security), - onBack = onBack, - configState = formState, - enabled = state.connected, - responseState = state.responseState, - onDismissPacketResponse = viewModel::clearPacketResponse, - onSave = { - val config = Config(security = it) - viewModel.setConfig(config) - }, - ) { - item { - TitledCard(title = stringResource(Res.string.direct_message_key)) { - EditBase64Preference( - title = stringResource(Res.string.public_key), - summary = stringResource(Res.string.config_security_public_key), - value = publicKey, - enabled = state.connected, - readOnly = true, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChange = { - if (it.size == 32) { - formState.value = formState.value.copy(public_key = it) - } - }, - trailingIcon = { CopyIconButton(valueToCopy = formState.value.public_key.encodeToString()) }, - ) - HorizontalDivider() - EditBase64Preference( - title = stringResource(Res.string.private_key), - summary = stringResource(Res.string.config_security_private_key), - value = formState.value.private_key, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChange = { - if (it.size == 32) { - formState.value = formState.value.copy(private_key = it) - } - }, - trailingIcon = { CopyIconButton(valueToCopy = formState.value.private_key.encodeToString()) }, - ) - HorizontalDivider() - NodeActionButton( - modifier = Modifier.padding(horizontal = 8.dp), - title = stringResource(Res.string.regenerate_private_key), - enabled = state.connected, - icon = Icons.TwoTone.Warning, - onClick = { showKeyGenerationDialog = true }, - ) - } - } - item { - TitledCard(title = stringResource(Res.string.admin_keys)) { - EditListPreference( - title = stringResource(Res.string.admin_key), - summary = stringResource(Res.string.config_security_admin_key), - list = formState.value.admin_key, - maxCount = 3, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValuesChanged = { formState.value = formState.value.copy(admin_key = it) }, - ) - } - } - item { - TitledCard(title = stringResource(Res.string.logs)) { - SwitchPreference( - title = stringResource(Res.string.serial_console), - summary = stringResource(Res.string.config_security_serial_enabled), - checked = formState.value.serial_enabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(serial_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.debug_log_api_enabled), - summary = stringResource(Res.string.config_security_debug_log_api_enabled), - checked = formState.value.debug_log_api_enabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(debug_log_api_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - item { - TitledCard(title = stringResource(Res.string.administration)) { - SwitchPreference( - title = stringResource(Res.string.managed_mode), - summary = stringResource(Res.string.config_security_is_managed), - checked = formState.value.is_managed, - enabled = state.connected && formState.value.admin_key.isNotEmpty(), - onCheckedChange = { formState.value = formState.value.copy(is_managed = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.legacy_admin_channel), - checked = formState.value.admin_channel_enabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - } -} - -@Suppress("MagicNumber") -@Composable -private fun DesktopPrivateKeyRegenerateDialog(onConfirm: (Config.SecurityConfig) -> Unit, onDismiss: () -> Unit = {}) { - MeshtasticResourceDialog( - onDismiss = onDismiss, - titleRes = Res.string.regenerate_private_key, - messageRes = Res.string.regenerate_keys_confirmation, - onConfirm = { - // Generate a random "f" value - val f = ByteArray(32).apply { SecureRandom().nextBytes(this) } - // Adjust the value to make it valid as an "s" value for eval(). - // According to the specification we need to mask off the 3 - // right-most bits of f[0], mask off the left-most bit of f[31], - // and set the second to left-most bit of f[31]. - f[0] = (f[0].toInt() and 0xF8).toByte() - f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte() - val securityInput = Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY) - onConfirm(securityInput) - }, - ) -} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt index 547c4f5c9..cd7095eae 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -18,10 +18,6 @@ package org.meshtastic.feature.settings.navigation import androidx.compose.runtime.Composable import org.meshtastic.core.navigation.Route -import org.meshtastic.feature.settings.DesktopDeviceConfigScreen -import org.meshtastic.feature.settings.DesktopExternalNotificationConfigScreen -import org.meshtastic.feature.settings.DesktopPositionConfigScreen -import org.meshtastic.feature.settings.DesktopSecurityConfigScreen import org.meshtastic.feature.settings.DesktopSettingsScreen import org.meshtastic.feature.settings.SettingsViewModel import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -39,23 +35,3 @@ actual fun SettingsMainScreen( onNavigate = onNavigate, ) } - -@Composable -actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - DesktopDeviceConfigScreen(viewModel = viewModel, onBack = onBack) -} - -@Composable -actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - DesktopPositionConfigScreen(viewModel = viewModel, onBack = onBack) -} - -@Composable -actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - DesktopSecurityConfigScreen(viewModel = viewModel, onBack = onBack) -} - -@Composable -actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - DesktopExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack) -} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.jvm.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.jvm.kt new file mode 100644 index 000000000..36cb80d04 --- /dev/null +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.jvm.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.zone.ZoneOffsetTransitionRule +import java.util.Locale +import kotlin.math.abs + +@Composable actual fun rememberSystemTimeZonePosixString(): String = remember { ZoneId.systemDefault().toPosixString() } + +/** Generates a POSIX time zone string from a [ZoneId]. JVM/Desktop version of the Android-only `core:model` utility. */ +@Suppress("MagicNumber", "ReturnCount") +private fun ZoneId.toPosixString(): String { + val rules = this.rules + + if (rules.isFixedOffset || rules.transitionRules.isEmpty()) { + val now = java.time.Instant.now() + val zdt = ZonedDateTime.ofInstant(now, this) + return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}" + } + + val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds } + val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds } + + if (springRule == null || fallRule == null) { + val now = java.time.Instant.now() + val zdt = ZonedDateTime.ofInstant(now, this) + return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}" + } + + return buildString { + val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule) + val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule) + + append(formatAbbreviation(stdAbbrev)) + append(formatPosixOffset(springRule.offsetBefore)) + append(formatAbbreviation(dstAbbrev)) + + if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) { + append(formatPosixOffset(springRule.offsetAfter)) + } + + append(formatTransitionRule(springRule)) + append(formatTransitionRule(fallRule)) + } +} + +private fun ZonedDateTime.timeZoneShortName(): String { + val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH) + val shortName = format(formatter) + return if (shortName.startsWith("GMT")) "GMT" else shortName +} + +private fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>" + +private fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String { + val year = java.time.LocalDate.now().year + val transition = rule.createTransition(year) + return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName() +} + +@Suppress("MagicNumber") +private fun formatPosixOffset(offset: ZoneOffset): String { + val offsetSeconds = -offset.totalSeconds + val hours = offsetSeconds / 3600 + val remainingSeconds = abs(offsetSeconds) % 3600 + val minutes = remainingSeconds / 60 + val seconds = remainingSeconds % 60 + + return buildString { + if (offsetSeconds < 0 && hours == 0) append("-") + append(hours) + if (minutes != 0 || seconds != 0) { + append(":%02d".format(Locale.ENGLISH, minutes)) + if (seconds != 0) { + append(":%02d".format(Locale.ENGLISH, seconds)) + } + } + } +} + +@Suppress("MagicNumber") +private fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String { + val month = rule.month.value + val dayOfWeek = rule.dayOfWeek.value % 7 + val dayIndicator = rule.dayOfMonthIndicator + + val occurrence = + when { + dayIndicator < 0 -> 5 + dayIndicator > rule.month.length(false) - 7 -> 5 + else -> ((dayIndicator - 1) / 7) + 1 + } + + val wallTime = + when (rule.timeDefinition) { + ZoneOffsetTransitionRule.TimeDefinition.UTC -> + rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong()) + + ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> { + if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) { + rule.localTime + } else { + rule.localTime.plusSeconds( + (rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(), + ) + } + } + + else -> rule.localTime + } + + return buildString { + append(",M$month.$occurrence.$dayOfWeek") + if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) { + append("/${wallTime.hour}") + if (wallTime.minute != 0 || wallTime.second != 0) { + append(":%02d".format(Locale.ENGLISH, wallTime.minute)) + if (wallTime.second != 0) { + append(":%02d".format(Locale.ENGLISH, wallTime.second)) + } + } + } + } +} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.jvm.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.jvm.kt new file mode 100644 index 000000000..b816e32e6 --- /dev/null +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.jvm.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable + +@Composable +actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { + // Currently, there is no file picker or media player natively wired up + // for ringtone import and playback on Desktop. +} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.jvm.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.jvm.kt new file mode 100644 index 000000000..d41fe34a3 --- /dev/null +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.jvm.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable +import org.meshtastic.core.model.Position +import org.meshtastic.feature.settings.radio.RadioConfigViewModel + +@Composable +actual fun DeviceLocationButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + onLocationReceived: (Position) -> Unit, +) { + // No-op for desktop since it doesn't have a phone GPS +} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt new file mode 100644 index 000000000..49c378953 --- /dev/null +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.runtime.Composable +import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.proto.Config + +@Composable +actual fun ExportSecurityConfigButton( + viewModel: RadioConfigViewModel, + enabled: Boolean, + securityConfig: Config.SecurityConfig, +) { + // Desktop currently does not implement a specific "export security config" button + // within the config screen. If it did, we'd add it here. +}