refactor: adaptive UI components for Navigation 3 (#4891)

This commit is contained in:
James Rich 2026-03-23 12:35:02 -05:00 committed by GitHub
parent b3b38acc0b
commit 7b327215f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 978 additions and 1751 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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) } }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <T> AdaptiveListDetailScaffold(
navigator: ThreePaneScaffoldNavigator<T>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
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()
}
},
)
}

View file

@ -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
}
}
}
}

View file

@ -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.

View file

@ -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)

View file

@ -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<String>()
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),
)
},
)
}

View file

@ -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<Int>()
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))
},
)
}

View file

@ -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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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) },
)
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 },
)
}

View file

@ -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<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
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<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
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 <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,

View file

@ -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)),
)

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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) },
)
}

View file

@ -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],

View file

@ -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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
},
)
}

View file

@ -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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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.
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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.
}