mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: adaptive UI components for Navigation 3 (#4891)
This commit is contained in:
parent
b3b38acc0b
commit
7b327215f3
35 changed files with 978 additions and 1751 deletions
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue