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.
+}