From d136b162a428ae852930bcef2df42d237308bea3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:19:13 -0500 Subject: [PATCH] feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876) --- .github/copilot-instructions.md | 24 +- .github/workflows/reusable-check.yml | 4 +- .gitignore | 2 +- .jdk | 1 + AGENTS.md | 8 +- GEMINI.md | 8 +- app/detekt-baseline.xml | 29 +- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 3 +- .../meshtastic/buildlogic/KotlinAndroid.kt | 30 ++ .../org/meshtastic/core/ble/NoopStubs.kt | 28 + .../core/common/util/Dispatchers.kt | 22 + .../meshtastic/core/common/util/Formatter.kt | 20 + .../HomoglyphCharacterStringTransformer.kt | 2 +- .../core/common/util/Dispatchers.kt | 22 + .../meshtastic/core/common/util/Formatter.kt | 22 + .../meshtastic/core/common/util/NoopStubs.kt | 92 ++++ .../core/common/util/Dispatchers.kt | 22 + .../meshtastic/core/common/util/Formatter.kt | 20 + .../core/data/manager/CommandSenderImpl.kt | 4 +- .../data/manager/MeshActionHandlerImpl.kt | 4 +- .../data/manager/MeshConfigFlowManagerImpl.kt | 4 +- .../data/manager/MeshConfigHandlerImpl.kt | 4 +- .../data/manager/MeshConnectionManagerImpl.kt | 4 +- .../core/data/manager/MeshDataHandlerImpl.kt | 44 +- .../data/manager/MeshMessageProcessorImpl.kt | 4 +- .../core/data/manager/MqttManagerImpl.kt | 4 +- .../data/manager/NeighborInfoHandlerImpl.kt | 4 +- .../core/data/manager/NodeManagerImpl.kt | 22 +- .../core/data/manager/PacketHandlerImpl.kt | 4 +- .../data/manager/TracerouteHandlerImpl.kt | 4 +- .../core/database/MeshtasticDatabase.kt | 9 +- .../core/database/DatabaseBuilder.kt | 30 +- .../core/datastore/UiPreferencesDataSource.kt | 4 +- .../core/datastore/di/CoreDatastoreModule.kt | 4 +- core/di/build.gradle.kts | 7 +- .../org/meshtastic/core/di/di/CoreDiModule.kt | 3 +- core/domain/build.gradle.kts | 2 - .../core/model/util/AndroidDateTimeUtils.kt | 10 - .../org/meshtastic/core/model/Channel.kt | 2 +- .../org/meshtastic/core/model/DataPacket.kt | 3 +- .../kotlin/org/meshtastic/core/model/Node.kt | 17 +- .../core/model/util/DateTimeUtils.kt | 15 + .../core/model/util/DistanceExtensions.kt | 22 +- .../core/model/util/SharedContact.kt | 4 +- .../meshtastic/core/model/util/NoopStubs.kt | 26 + core/navigation/build.gradle.kts | 2 - .../core/navigation/NavigationConfig.kt | 116 ++++ core/network/build.gradle.kts | 2 - .../core/network/radio/MockInterface.kt | 4 +- core/nfc/build.gradle.kts | 2 - core/prefs/build.gradle.kts | 2 - core/proto/build.gradle.kts | 3 - core/repository/build.gradle.kts | 2 - .../meshtastic/core/repository/Location.kt | 20 + core/resources/build.gradle.kts | 5 +- .../meshtastic/core/resources/GetString.kt | 23 +- core/service/build.gradle.kts | 3 +- .../service/SharedRadioInterfaceService.kt | 66 +-- core/testing/build.gradle.kts | 2 - core/ui/build.gradle.kts | 6 +- .../core/ui/component/DropDownPreference.kt | 1 + .../core/ui/component/EditTextPreference.kt | 4 +- .../core/ui/component/LoraSignalIndicator.kt | 5 +- .../core/ui/component/MaterialBatteryInfo.kt | 5 +- .../core/ui/component/ScrollToTopEvent.kt | 6 + .../core/ui/component/SignalInfo.kt | 3 +- .../core/ui/qr/ScannedQrCodeDialog.kt | 4 +- .../meshtastic/core/ui/component/NoopStubs.kt | 25 + .../org/meshtastic/core/ui/theme/NoopStubs.kt | 22 + .../org/meshtastic/core/ui/util/NoopStubs.kt | 40 ++ desktop/README.md | 25 +- desktop/build.gradle.kts | 1 + .../kotlin/org/meshtastic/desktop/Main.kt | 4 +- .../navigation/DesktopMessagingNavigation.kt | 83 --- .../desktop/navigation/DesktopNavigation.kt | 46 +- .../navigation/DesktopNodeNavigation.kt | 129 ----- .../navigation/DesktopSettingsNavigation.kt | 228 -------- .../desktop/ui/DesktopMainScreen.kt | 95 ---- .../DesktopAdaptiveContactsScreen.kt | 165 ------ .../ui/messaging/DesktopMessageContent.kt | 507 ------------------ .../ui/nodes/DesktopAdaptiveNodeListScreen.kt | 290 ---------- .../ui/settings/DesktopNetworkConfigScreen.kt | 260 --------- docs/agent-playbooks/README.md | 1 + docs/agent-playbooks/common-practices.md | 3 +- .../di-navigation3-anti-patterns-playbook.md | 11 +- docs/agent-playbooks/task-playbooks.md | 19 +- docs/decisions/architecture-review-2026-03.md | 6 +- docs/decisions/navigation3-parity-2026-03.md | 23 +- docs/kmp-status.md | 17 +- docs/roadmap.md | 9 +- feature/connections/build.gradle.kts | 2 - feature/connections/detekt-baseline.xml | 10 +- .../feature/connections/ScannerViewModel.kt | 3 +- .../navigation/ConnectionsNavigation.kt | 11 +- .../firmware/navigation/FirmwareScreen.kt | 28 + .../firmware/navigation/FirmwareNavigation.kt | 12 +- .../firmware/navigation/FirmwareNavigation.kt | 24 + .../firmware/DesktopFirmwareScreen.kt | 2 +- .../firmware/navigation/FirmwareScreen.kt | 25 + feature/intro/build.gradle.kts | 2 - feature/map/build.gradle.kts | 2 - .../feature/map/navigation/MapMainScreen.kt | 33 ++ .../feature/map/BaseMapViewModel.kt | 6 +- .../feature/map/navigation/MapNavigation.kt | 11 +- .../feature/map/navigation/MapNavigation.kt | 24 + .../feature/map/navigation/MapMainScreen.kt | 40 ++ feature/messaging/build.gradle.kts | 7 +- .../navigation/ContactsEntryContent.kt | 59 ++ .../messaging/component/MessageItemTest.kt | 2 +- .../meshtastic/feature/messaging/Message.kt | 11 +- .../feature/messaging/MessageListPaged.kt | 0 .../feature/messaging/MessageViewModel.kt | 8 +- .../meshtastic/feature/messaging/QuickChat.kt | 2 +- .../feature/messaging/QuickChatViewModel.kt | 8 +- .../navigation/ContactsNavigation.kt | 33 +- .../ui/contact/AdaptiveContactsScreen.kt | 52 +- .../feature/messaging/ui/contact/Contacts.kt | 23 +- .../messaging/ui/contact/ContactsViewModel.kt | 10 +- .../navigation/ContactsNavigation.kt | 33 ++ .../navigation/ContactsEntryContent.kt | 64 +++ .../feature/node/detail/NodeDetailScreen.kt | 12 +- .../feature/node/metrics/PositionLog.kt | 2 +- .../node/navigation/TracerouteMapScreens.kt | 36 ++ .../feature/node/compass/CompassViewModel.kt | 3 +- .../node/component/CompassBottomSheet.kt | 16 +- .../node/component/NodeDetailsSection.kt | 5 +- .../feature/node/component/NodeItem.kt | 23 +- .../node/detail/CommonNodeRequestActions.kt | 12 +- .../feature/node/detail/NodeDetailScreens.kt | 33 ++ .../node/detail/NodeManagementActions.kt | 12 +- .../feature/node/list/NodeListScreen.kt | 8 +- .../feature/node/metrics/DeviceMetrics.kt | 24 +- .../feature/node/metrics/EnvironmentCharts.kt | 7 +- .../node/metrics/EnvironmentMetrics.kt | 25 +- .../feature/node/metrics/MetricsViewModel.kt | 5 +- .../feature/node/metrics/PaxMetrics.kt | 9 +- .../node/metrics/PositionLogComponents.kt | 7 +- .../node/metrics/PositionLogScreens.kt | 21 + .../feature/node/metrics/PowerMetrics.kt | 15 +- .../feature/node/metrics/SignalMetrics.kt | 13 +- .../feature/node/metrics/TracerouteLog.kt | 4 +- .../node/navigation/AdaptiveNodeListScreen.kt | 16 +- .../node/navigation/NodesNavigation.kt | 20 +- .../feature/node/detail/NodeDetailScreens.kt | 35 ++ .../node/metrics/PositionLogScreens.kt | 24 + .../node/navigation/TracerouteMapScreens.kt | 24 + .../feature/node/detail/NodeDetailScreens.kt | 70 +++ .../node/metrics/PositionLogScreens.kt | 36 ++ .../node/navigation/TracerouteMapScreens.kt | 42 ++ feature/settings/detekt-baseline.xml | 2 - .../navigation/AboutLibrariesLoader.kt | 22 + .../settings/navigation/SettingsMainScreen.kt | 62 +++ .../settings/debugging/DebugViewModel.kt | 5 +- .../navigation/AboutLibrariesLoader.kt | 19 + .../settings/navigation/SettingsNavigation.kt | 62 ++- .../radio/component/LoadingOverlay.kt | 3 +- .../radio/component/NetworkConfigItemList.kt | 181 +++---- .../component/PacketResponseStateDialog.kt | 3 +- .../feature/settings/debugging/NoopStubs.kt | 24 + .../navigation/AboutLibrariesLoader.kt | 21 + .../settings/navigation/SettingsNavigation.kt | 52 ++ .../settings/DesktopDeviceConfigScreen.kt | 2 +- ...DesktopExternalNotificationConfigScreen.kt | 2 +- .../settings/DesktopPositionConfigScreen.kt | 2 +- .../settings/DesktopSecurityConfigScreen.kt | 2 +- .../settings/DesktopSettingsScreen.kt | 3 +- .../navigation/AboutLibrariesLoader.kt | 22 + .../settings/navigation/SettingsMainScreen.kt | 61 +++ fix_dispatchers.py | 63 +++ gradle/libs.versions.toml | 3 + 170 files changed, 2208 insertions(+), 2432 deletions(-) create mode 120000 .jdk create mode 100644 core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt create mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt create mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt create mode 100644 core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt create mode 100644 core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt create mode 100644 core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt rename feature/connections/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt (83%) create mode 100644 feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt rename feature/firmware/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt (72%) create mode 100644 feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature}/firmware/DesktopFirmwareScreen.kt (99%) create mode 100644 feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt rename feature/map/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt (83%) create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt create mode 100644 feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/Message.kt (98%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt (100%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt (67%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt (78%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt (97%) create mode 100644 feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt create mode 100644 feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt create mode 100644 feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt (96%) create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt (91%) rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt (94%) create mode 100644 feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt create mode 100644 feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt create mode 100644 feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt create mode 100644 feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt create mode 100644 feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt create mode 100644 feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt rename feature/settings/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt (88%) rename feature/settings/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt (71%) create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopDeviceConfigScreen.kt (99%) rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopExternalNotificationConfigScreen.kt (99%) rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopPositionConfigScreen.kt (99%) rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopSecurityConfigScreen.kt (99%) rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopSettingsScreen.kt (99%) create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt create mode 100644 fix_dispatchers.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e828b3671..fcb614b12 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,11 +14,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - `fdroid`: Open source only, no tracking/analytics. - `google`: Includes Google Play Services (Maps) and DataDog analytics. - **Core Architecture:** Modern Android Development (MAD) with KMP core. - - **KMP Modules:** Most `core:*` modules. All declare `jvm()` target and compile clean on JVM. + - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. - - **UI:** Jetpack Compose (Material 3). - - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app`. - - **Navigation:** AndroidX Navigation 3 (JetBrains multiplatform fork) with shared backstack state. + - **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. - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`. - **Database:** Room KMP. @@ -38,7 +38,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). | | `core:domain` | Pure KMP business logic and UseCases. | | `core:data` | Core manager implementations and data orchestration. | -| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec` in commonMain, `TcpTransport` in jvmAndroidMain). | +| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). | | `core:di` | Common DI qualifiers and dispatchers. | | `core:navigation` | Shared navigation keys/routes for Navigation 3. | | `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. | @@ -47,18 +47,18 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `core:barcode` | Barcode scanning (Android-only). | | `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. | -| `core/ble/` | Bluetooth Low Energy stack using Nordic libraries. | +| `core/ble/` | Bluetooth Low Energy stack using Kable. | | `core/resources/` | Centralized string and image resources (Compose Multiplatform). | | `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. Use `meshtastic.kmp.feature` convention plugin. | -| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP transport with `want_config` handshake. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | | `mesh_service_example/` | Sample app showing `core:api` service integration. | ## 3. Development Guidelines & Coding Standards ### A. UI Development (Jetpack Compose) - **Material 3:** The app uses Material 3. -- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). NEVER use hardcoded strings. +- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. @@ -72,11 +72,13 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Concurrency:** Use Kotlin Coroutines and Flow. - **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. -- **BLE:** All Bluetooth communication must route through `core:ble` using Nordic Semiconductor's Android Common Libraries. +- **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. - **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. +- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. -- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `core:testing` shared fakes. +- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code. +- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. - **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. ### C. Namespacing diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 3fa8dff85..6fdbecfb8 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -91,8 +91,8 @@ jobs: if: inputs.run_unit_tests == true run: ./gradlew test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport -Pci=true --continue --scan - - name: KMP JVM Smoke Compile - run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm -Pci=true --continue --scan + - name: KMP Smoke Compile + run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm :core:proto:compileKotlinIosSimulatorArm64 :core:common:compileKotlinIosSimulatorArm64 :core:model:compileKotlinIosSimulatorArm64 :core:repository:compileKotlinIosSimulatorArm64 :core:di:compileKotlinIosSimulatorArm64 :core:navigation:compileKotlinIosSimulatorArm64 :core:resources:compileKotlinIosSimulatorArm64 :core:datastore:compileKotlinIosSimulatorArm64 :core:database:compileKotlinIosSimulatorArm64 :core:domain:compileKotlinIosSimulatorArm64 :core:prefs:compileKotlinIosSimulatorArm64 :core:network:compileKotlinIosSimulatorArm64 :core:data:compileKotlinIosSimulatorArm64 :core:ble:compileKotlinIosSimulatorArm64 :core:nfc:compileKotlinIosSimulatorArm64 :core:service:compileKotlinIosSimulatorArm64 :core:testing:compileKotlinIosSimulatorArm64 :core:ui:compileKotlinIosSimulatorArm64 :feature:intro:compileKotlinIosSimulatorArm64 :feature:messaging:compileKotlinIosSimulatorArm64 :feature:connections:compileKotlinIosSimulatorArm64 :feature:map:compileKotlinIosSimulatorArm64 :feature:node:compileKotlinIosSimulatorArm64 :feature:settings:compileKotlinIosSimulatorArm64 :feature:firmware:compileKotlinIosSimulatorArm64 -Pci=true --continue --scan - name: Upload coverage results to Codecov if: ${{ !cancelled() && inputs.run_unit_tests }} diff --git a/.gitignore b/.gitignore index 0c80d6537..4a057e39f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,4 @@ wireless-install.sh # Git worktrees .worktrees/ -/firebase-debug.log \ No newline at end of file +/firebase-debug.log.jdk/ diff --git a/.jdk b/.jdk new file mode 120000 index 000000000..096e1a9e3 --- /dev/null +++ b/.jdk @@ -0,0 +1 @@ +/home/james/.jdks/ms-17.0.18 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 9e5f6d412..fcb614b12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - `fdroid`: Open source only, no tracking/analytics. - `google`: Includes Google Play Services (Maps) and DataDog analytics. - **Core Architecture:** Modern Android Development (MAD) with KMP core. - - **KMP Modules:** Most `core:*` modules. All declare `jvm()` target and compile clean on JVM. + - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. - **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`. @@ -50,15 +50,15 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core/ble/` | Bluetooth Low Energy stack using Kable. | | `core/resources/` | Centralized string and image resources (Compose Multiplatform). | | `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` target except `widget`. Use `meshtastic.kmp.feature` convention plugin. | -| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | | `mesh_service_example/` | Sample app showing `core:api` service integration. | ## 3. Development Guidelines & Coding Standards ### A. UI Development (Jetpack Compose) - **Material 3:** The app uses Material 3. -- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). NEVER use hardcoded strings. +- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. diff --git a/GEMINI.md b/GEMINI.md index 9e5f6d412..fcb614b12 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -14,7 +14,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - `fdroid`: Open source only, no tracking/analytics. - `google`: Includes Google Play Services (Maps) and DataDog analytics. - **Core Architecture:** Modern Android Development (MAD) with KMP core. - - **KMP Modules:** Most `core:*` modules. All declare `jvm()` target and compile clean on JVM. + - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. - **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`. @@ -50,15 +50,15 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core/ble/` | Bluetooth Low Energy stack using Kable. | | `core/resources/` | Centralized string and image resources (Compose Multiplatform). | | `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` target except `widget`. Use `meshtastic.kmp.feature` convention plugin. | -| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | | `mesh_service_example/` | Sample app showing `core:api` service integration. | ## 3. Development Guidelines & Coding Standards ### A. UI Development (Jetpack Compose) - **Material 3:** The app uses Material 3. -- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). NEVER use hardcoded strings. +- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 876b1b215..c373eea43 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -1,32 +1,5 @@ - - LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect() - LongParameterList:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, dispatchers: CoroutineDispatchers, meshLogRepository: MeshLogRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, tracerouteSnapshotRepository: TracerouteSnapshotRepository, nodeRequestActions: NodeRequestActions, alertManager: AlertManager, getNodeDetailsUseCase: GetNodeDetailsUseCase, ) - LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, ) - LongParameterList:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, radioConfigRepository: RadioConfigRepository, packetRepository: PacketRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val locationRepository: LocationRepository, mapConsentPrefs: MapConsentPrefs, analyticsPrefs: AnalyticsPrefs, homoglyphEncodingPrefs: HomoglyphPrefs, toggleAnalyticsUseCase: ToggleAnalyticsUseCase, toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, importProfileUseCase: ImportProfileUseCase, exportProfileUseCase: ExportProfileUseCase, exportSecurityConfigUseCase: ExportSecurityConfigUseCase, installProfileUseCase: InstallProfileUseCase, radioConfigUseCase: RadioConfigUseCase, adminActionsUseCase: AdminActionsUseCase, processRadioResponseUseCase: ProcessRadioResponseUseCase, ) - LongParameterList:AndroidSettingsViewModel.kt$AndroidSettingsViewModel$( private val app: Application, radioConfigRepository: RadioConfigRepository, radioController: RadioController, nodeRepository: NodeRepository, uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, databaseManager: DatabaseManager, meshLogPrefs: MeshLogPrefs, setThemeUseCase: SetThemeUseCase, setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, setProvideLocationUseCase: SetProvideLocationUseCase, setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, meshLocationUseCase: MeshLocationUseCase, exportDataUseCase: ExportDataUseCase, isOtaCapableUseCase: IsOtaCapableUseCase, ) - MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L - MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5 - MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114 - MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200 - MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200 - MagicNumber:StreamInterface.kt$StreamInterface$0xff - MagicNumber:StreamInterface.kt$StreamInterface$3 - MagicNumber:StreamInterface.kt$StreamInterface$4 - MagicNumber:StreamInterface.kt$StreamInterface$8 - MagicNumber:TCPInterface.kt$TCPInterface$1000 - SwallowedException:NsdManager.kt$ex: IllegalArgumentException - SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException - TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception - TooGenericExceptionCaught:BleRadioInterface.kt$BleRadioInterface$e: Exception - TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable - TooManyFunctions:BleRadioInterface.kt$BleRadioInterface : RadioTransport ->>>>>>> ba83c3564 (chore(conductor): Complete Phase 4 - Wire Kable and Remove Nordic) - + diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 346be20af..66bd779d0 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -71,6 +71,7 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.navigation.ContactsRoutes +import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig import org.meshtastic.core.navigation.NodeDetailRoutes import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.TopLevelDestination @@ -113,7 +114,7 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) { - val backStack = rememberNavBackStack(NodesRoutes.NodesGraph as NavKey) + val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey) // LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) } // } val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle() diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 984736838..39c965255 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -67,6 +67,14 @@ internal fun Project.configureKotlinAndroid( */ internal fun Project.configureKotlinMultiplatform() { extensions.configure { + // Standard KMP targets for Meshtastic + jvm() + + // Configure the iOS targets for compile-only validation + // We only add these for modules that already have KMP structure + iosArm64() + iosSimulatorArm64() + // Configure the Android target if the plugin is applied pluginManager.withPlugin("com.android.kotlin.multiplatform.library") { extensions.findByType()?.apply { @@ -166,6 +174,27 @@ private inline fun Project.configureKotlin() { // Using Java 17 for better compatibility with consumers (e.g. plugins, older environments) // while still supporting modern Kotlin features. jvmToolchain(17) + + if (this is KotlinMultiplatformExtension) { + targets.configureEach { + compilations.configureEach { + compileTaskProvider.configure { + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlin.uuid.ExperimentalUuidApi", + "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", + "-Xexpect-actual-classes", + "-Xcontext-parameters", + "-Xannotation-default-target=param-property", + "-Xskip-prerelease-check" + ) + } + } + } + } + } } tasks.withType().configureEach { @@ -177,6 +206,7 @@ private inline fun Project.configureKotlin() { "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlin.uuid.ExperimentalUuidApi", "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", "-Xexpect-actual-classes", "-Xcontext-parameters", "-Xannotation-default-target=param-property", diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt new file mode 100644 index 000000000..1ab2d0814 --- /dev/null +++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import com.juul.kable.Peripheral +import com.juul.kable.PeripheralBuilder + +/** No-op stubs for iOS target in core:ble. */ +internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) { + // No-op for stubs +} + +internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral = + throw UnsupportedOperationException("iOS Peripheral not yet implemented") diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt new file mode 100644 index 000000000..73d686700 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlinx.coroutines.CoroutineDispatcher + +/** Access to the IO dispatcher in a multiplatform-safe way. */ +expect val ioDispatcher: CoroutineDispatcher diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt new file mode 100644 index 000000000..d54455df8 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** Multiplatform string formatting helper. */ +expect fun formatString(pattern: String, vararg args: Any?): String diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt index d91c02b7e..e3612dfda 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt @@ -81,7 +81,7 @@ object HomoglyphCharacterStringTransformer { */ fun optimizeUtf8StringWithHomoglyphs(value: String): String { val stringBuilder = StringBuilder() - for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping.getOrDefault(c, c)) + for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c) return stringBuilder.toString() } } diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt new file mode 100644 index 000000000..86c423b73 --- /dev/null +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt new file mode 100644 index 000000000..6d1acf46f --- /dev/null +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** Apple (iOS) implementation of string formatting. Stub implementation for compile-only validation. */ +actual fun formatString(pattern: String, vararg args: Any?): String = throw UnsupportedOperationException( + "formatString is not supported on iOS at runtime; this target is intended for compile-only validation.", +) diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt new file mode 100644 index 000000000..35e2906ff --- /dev/null +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** No-op stubs for iOS target in core:common. */ +actual object BuildUtils { + actual val isEmulator: Boolean = false + actual val sdkInt: Int = 0 +} + +actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) { + actual fun getQueryParameter(key: String): String? = null + + actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue + + actual override fun toString(): String = "" + + actual companion object { + actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList()) + } +} + +actual fun CommonUri.toPlatformUri(): Any = Any() + +actual object DateFormatter { + actual fun formatRelativeTime(timestampMillis: Long): String = "" + + actual fun formatDateTime(timestampMillis: Long): String = "" + + actual fun formatShortDate(timestampMillis: Long): String = "" + + actual fun formatTime(timestampMillis: Long): String = "" + + actual fun formatTimeWithSeconds(timestampMillis: Long): String = "" + + actual fun formatDate(timestampMillis: Long): String = "" + + actual fun formatDateTimeShort(timestampMillis: Long): String = "" +} + +actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.METRIC + +actual fun String?.isValidAddress(): Boolean = false + +actual interface CommonParcelable + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.BINARY) +actual annotation class CommonParcelize actual constructor() + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +actual annotation class CommonIgnoredOnParcel actual constructor() + +actual interface CommonParceler { + actual fun create(parcel: CommonParcel): T + + actual fun T.write(parcel: CommonParcel, flags: Int) +} + +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +@Repeatable +actual annotation class CommonTypeParceler> actual constructor() + +actual class CommonParcel { + actual fun readString(): String? = null + + actual fun readInt(): Int = 0 + + actual fun readLong(): Long = 0L + + actual fun readFloat(): Float = 0.0f + + actual fun createByteArray(): ByteArray? = null + + actual fun writeByteArray(b: ByteArray?) {} +} diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt new file mode 100644 index 000000000..fa9e65661 --- /dev/null +++ b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt new file mode 100644 index 000000000..a450b9856 --- /dev/null +++ b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** JVM/Android implementation of string formatting. */ +actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index 1e5f5eaeb..8084a9507 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn @@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.onEach import okio.ByteString import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus @@ -58,7 +58,7 @@ class CommandSenderImpl( private val nodeManager: NodeManager, private val radioConfigRepository: RadioConfigRepository, ) : CommandSender { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue) private val sessionPasskey = atomic(ByteString.EMPTY) override val tracerouteStartTimes = mutableMapOf() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index b1a33330d..e89b11c3d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -17,13 +17,13 @@ package org.meshtastic.core.data.manager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ignoreException +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshUser @@ -64,7 +64,7 @@ class MeshActionHandlerImpl( private val notificationManager: NotificationManager, private val messageProcessor: Lazy, ) : MeshActionHandler { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) override fun start(scope: CoroutineScope) { this.scope = scope diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index ff20feddb..d7d0cebfe 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -18,12 +18,12 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import okio.IOException import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigFlowManager @@ -56,7 +56,7 @@ class MeshConfigFlowManagerImpl( private val commandSender: CommandSender, private val packetHandler: PacketHandler, ) : MeshConfigFlowManager { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private val configOnlyNonce = 69420 private val nodeInfoNonce = 69421 private val wantConfigDelay = 100L diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index 652e3bb79..b8263c253 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.data.manager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioConfigRepository @@ -41,7 +41,7 @@ class MeshConfigHandlerImpl( private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, ) : MeshConfigHandler { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private val _localConfig = MutableStateFlow(LocalConfig()) override val localConfig = _localConfig.asStateFlow() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 5e706c288..02192894b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.ConnectionState @@ -89,7 +89,7 @@ class MeshConnectionManagerImpl( private val workerManager: MeshWorkerManager, private val appWidgetUpdater: AppWidgetUpdater, ) : MeshConnectionManager { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var sleepTimeout: Job? = null private var locationRequestsJob: Job? = null private var handshakeTimeout: Job? = null diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 6e029545d..64811d0e9 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first @@ -30,6 +29,7 @@ import okio.ByteString.Companion.toByteString import okio.IOException import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket @@ -63,7 +63,7 @@ import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.critical_alert import org.meshtastic.core.resources.error_duty_cycle -import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title import org.meshtastic.core.resources.unknown_username @@ -114,7 +114,7 @@ class MeshDataHandlerImpl( private val radioConfigRepository: RadioConfigRepository, private val messageFilter: MessageFilter, ) : MeshDataHandler { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) override fun start(scope: CoroutineScope) { this.scope = scope @@ -433,9 +433,13 @@ class MeshDataHandlerImpl( if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) { notificationManager.dispatch( Notification( - title = getString(Res.string.low_battery_title, nextNode.user.short_name), + title = + getStringSuspend( + Res.string.low_battery_title, + nextNode.user.short_name, + ), message = - getString( + getStringSuspend( Res.string.low_battery_message, nextNode.user.long_name, nextNode.deviceMetrics.battery_level ?: 0, @@ -502,7 +506,9 @@ class MeshDataHandlerImpl( val payload = packet.decoded?.payload ?: return val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) { - serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle), Severity.Warn) + scope.launch { + serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn) + } } handleAckNak( packet.decoded?.request_id ?: 0, @@ -659,25 +665,27 @@ class MeshDataHandlerImpl( val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true val isSilent = conversationMuted || nodeMuted if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { - notificationManager.dispatch( - Notification( - title = getSenderName(dataPacket), - message = dataPacket.alert ?: getString(Res.string.critical_alert), - category = Notification.Category.Alert, - contactKey = contactKey, - ), - ) + scope.launch { + notificationManager.dispatch( + Notification( + title = getSenderName(dataPacket), + message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert), + category = Notification.Category.Alert, + contactKey = contactKey, + ), + ) + } } else if (updateNotification && !isSilent) { scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) } } } - private fun getSenderName(packet: DataPacket): String { + private suspend fun getSenderName(packet: DataPacket): String { if (packet.from == DataPacket.ID_LOCAL) { val myId = nodeManager.getMyId() - return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username) + return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } - return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(Res.string.unknown_username) + return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) { @@ -701,7 +709,7 @@ class MeshDataHandlerImpl( } PortNum.WAYPOINT_APP.value -> { - val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name) + val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name) notificationManager.dispatch( Notification( title = getSenderName(dataPacket), diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index fb68ee906..0c9ceaa2b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.launchIn @@ -28,6 +27,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.MeshLog @@ -55,7 +55,7 @@ class MeshMessageProcessorImpl( private val router: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, ) : MeshMessageProcessor { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private val mapsMutex = Mutex() private val logUuidByPacketId = mutableMapOf() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index c1b064efb..9b2a0c5e4 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -19,13 +19,13 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketHandler @@ -39,7 +39,7 @@ class MqttManagerImpl( private val packetHandler: PacketHandler, private val serviceRepository: ServiceRepository, ) : MqttManager { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var mqttMessageFlow: Job? = null override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 5eb40d4b0..631f2e4ca 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -18,10 +18,10 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.NeighborInfoHandler @@ -38,7 +38,7 @@ class NeighborInfoHandlerImpl( private val commandSender: CommandSender, private val serviceBroadcasts: ServiceBroadcasts, ) : NeighborInfoHandler { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) override fun start(scope: CoroutineScope) { this.scope = scope diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index dd554e6ea..d674c4b5a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -21,13 +21,13 @@ import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import okio.ByteString import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceMetrics import org.meshtastic.core.model.EnvironmentMetrics @@ -43,7 +43,7 @@ import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.new_node_seen import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.HardwareModel @@ -62,7 +62,7 @@ class NodeManagerImpl( private val serviceBroadcasts: ServiceBroadcasts, private val notificationManager: NotificationManager, ) : NodeManager { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private val _nodeDBbyNodeNum = atomic(persistentMapOf()) private val _nodeDBbyID = atomic(persistentMapOf()) @@ -196,13 +196,15 @@ class NodeManagerImpl( node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified) } if (newNode && !shouldPreserve) { - notificationManager.dispatch( - Notification( - title = getString(Res.string.new_node_seen, next.user.short_name), - message = next.user.long_name, - category = Notification.Category.NodeEvent, - ), - ) + scope.handledLaunch { + notificationManager.dispatch( + Notification( + title = getStringSuspend(Res.string.new_node_seen, next.user.short_name), + message = next.user.long_name, + category = Notification.Category.NodeEvent, + ), + ) + } } next } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index 56a664f8e..0ac6ef9ce 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay @@ -30,6 +29,7 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket @@ -67,7 +67,7 @@ class PacketHandlerImpl( } private var queueJob: Job? = null - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher) private val queueMutex = Mutex() private val queuedPackets = mutableListOf() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index 0d389b1d1..ae03186f0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -18,11 +18,11 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Node import org.meshtastic.core.model.fullRouteDiscovery @@ -44,7 +44,7 @@ class TracerouteHandlerImpl( private val nodeRepository: NodeRepository, private val commandSender: CommandSender, ) : TracerouteHandler { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) override fun start(scope: CoroutineScope) { this.scope = scope diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 29d9b17ec..ebb77a297 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -24,7 +24,7 @@ import androidx.room3.RoomDatabase import androidx.room3.TypeConverters import androidx.room3.migration.AutoMigrationSpec import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import kotlinx.coroutines.Dispatchers +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.dao.DeviceHardwareDao import org.meshtastic.core.database.dao.FirmwareReleaseDao import org.meshtastic.core.database.dao.MeshLogDao @@ -122,14 +122,15 @@ abstract class MeshtasticDatabase : RoomDatabase() { fun RoomDatabase.Builder.configureCommon(): RoomDatabase.Builder = this.fallbackToDestructiveMigration(dropAllTables = false) .setDriver(BundledSQLiteDriver()) - .setQueryCoroutineContext(Dispatchers.IO) + .setQueryCoroutineContext(ioDispatcher) } } -@DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo")) +@DeleteTable(tableName = "NodeInfo") +@DeleteTable(tableName = "MyNodeInfo") class AutoMigration12to13 : AutoMigrationSpec -@DeleteColumn.Entries(DeleteColumn(tableName = "packet", columnName = "reply_id")) +@DeleteColumn(tableName = "packet", columnName = "reply_id") class AutoMigration29to30 : AutoMigrationSpec @DeleteColumn(tableName = "packet", columnName = "retry_count") diff --git a/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt index 04da47d3f..718da5aea 100644 --- a/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt +++ b/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -17,11 +17,16 @@ package org.meshtastic.core.database import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.okio.OkioSerializer +import androidx.datastore.core.okio.OkioStorage import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import androidx.room3.Room import androidx.room3.RoomDatabase import kotlinx.cinterop.ExperimentalForeignApi +import okio.BufferedSink +import okio.BufferedSource import okio.FileSystem import okio.Path import okio.Path.Companion.toPath @@ -55,11 +60,32 @@ actual fun deleteDatabase(dbName: String) { /** Returns the system FileSystem for iOS. */ actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM +private object PreferencesSerializer : OkioSerializer { + override val defaultValue: Preferences + get() = emptyPreferences() + + override suspend fun readFrom(source: BufferedSource): Preferences { + // iOS stub: return an empty Preferences instance instead of crashing. + return emptyPreferences() + } + + override suspend fun writeTo(t: Preferences, sink: BufferedSink) { + // iOS stub: no-op to avoid crashing on write. + } +} + /** Creates an iOS DataStore for database preferences. */ actual fun createDatabaseDataStore(name: String): DataStore { val dir = documentDirectory() + "/datastore" NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null) - return PreferenceDataStoreFactory.create(produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() }) + return DataStoreFactory.create( + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = PreferencesSerializer, + producePath = { (dir + "/$name.preferences_pb").toPath() }, + ), + ) } @OptIn(ExperimentalForeignApi::class) diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt index acac4f39c..5277feb8f 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt @@ -23,7 +23,6 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -33,6 +32,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.UiPreferences +import org.meshtastic.core.common.util.ioDispatcher const val KEY_APP_INTRO_COMPLETED = "app_intro_completed" const val KEY_THEME = "theme" @@ -52,7 +52,7 @@ const val KEY_EXCLUDE_MQTT = "exclude-mqtt" open class UiPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore) : UiPreferences { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) // Start this flow eagerly, so app intro doesn't flash (when disabled) on cold app start. override val appIntroCompleted: StateFlow = diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt index 9ef808bc3..aa81f1ac6 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt @@ -17,17 +17,17 @@ package org.meshtastic.core.datastore.di import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher @Module @ComponentScan("org.meshtastic.core.datastore") class CoreDatastoreModule { @Single @Named("DataStoreScope") - fun provideDataStoreScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) } diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts index 57f4d2fd5..06e868655 100644 --- a/core/di/build.gradle.kts +++ b/core/di/build.gradle.kts @@ -29,5 +29,10 @@ kotlin { androidResources.enable = false } - sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) } } + sourceSets { + commonMain.dependencies { + implementation(projects.core.common) + implementation(libs.kotlinx.coroutines.core) + } + } } diff --git a/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt b/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt index 9ad24502a..0ad68db8a 100644 --- a/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt +++ b/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt @@ -19,11 +19,12 @@ package org.meshtastic.core.di.di import kotlinx.coroutines.Dispatchers import org.koin.core.annotation.Module import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.di.CoroutineDispatchers @Module class CoreDiModule { @Single fun provideCoroutineDispatchers(): CoroutineDispatchers = - CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default) + CoroutineDispatchers(io = ioDispatcher, main = Dispatchers.Main, default = Dispatchers.Default) } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 88166c417..e08765edb 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -21,8 +21,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.core.domain" diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt index ec8ddfa7b..473e482e2 100644 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt +++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt @@ -19,12 +19,8 @@ package org.meshtastic.core.model.util import org.meshtastic.core.common.util.nowInstant import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY import java.text.DateFormat -import kotlin.time.Duration import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.DurationUnit private val DAY_DURATION = 24.hours @@ -53,9 +49,3 @@ fun getShortDate(time: Long): String? { * @param remainingMillis The remaining time in milliseconds * @return Pair of (days, hours), where days is Int and hours is Double */ -fun formatMuteRemainingTime(remainingMillis: Long): Pair { - val duration = remainingMillis.milliseconds - if (duration <= Duration.ZERO) return 0 to 0.0 - val totalHours = duration.toDouble(DurationUnit.HOURS) - return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY) -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt index e3bf15d7c..7e19e0295 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt @@ -98,7 +98,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon cleartextPSK } else { // Treat an index of 1 as the old channelDefaultKey and work up from there - val bytes = channelDefaultKey.clone() + val bytes = channelDefaultKey.copyOf() bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte() bytes.toByteString() } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt index e7f0b44e4..1f69d4a0d 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.common.util.CommonParcel import org.meshtastic.core.common.util.CommonParcelable import org.meshtastic.core.common.util.CommonParcelize import org.meshtastic.core.common.util.CommonTypeParceler +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.util.ByteStringParceler import org.meshtastic.core.model.util.ByteStringSerializer @@ -190,7 +191,7 @@ data class DataPacket( // Public-key cryptography (PKC) channel index const val PKC_CHANNEL_INDEX = 8 - fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n) + fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n) @Suppress("MagicNumber") fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull() diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 55c4fefee..fe6cad31b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -20,6 +20,7 @@ import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.GPSFormat import org.meshtastic.core.common.util.bearing +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.latLongToMeter import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.onlineTimeThreshold @@ -143,20 +144,20 @@ data class Node( val temp = if ((temperature ?: 0f) != 0f) { if (isFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f)) + formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f)) } else { - "%.1f°C".format(temperature) + formatString("%.1f°C", temperature) } } else { null } - val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null + val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null val soilTemperatureStr = if ((soil_temperature ?: 0f) != 0f) { if (isFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f)) + formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f)) } else { - "%.1f°C".format(soil_temperature) + formatString("%.1f°C", soil_temperature) } } else { null @@ -164,12 +165,12 @@ data class Node( val soilMoistureRange = 0..100 val soilMoisture = if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) { - "%d%%".format(soil_moisture) + formatString("%d%%", soil_moisture) } else { null } - val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null - val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null + val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null + val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null return listOfNotNull( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt index 7241cb80e..79e2636a2 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.core.model.util +import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -46,3 +48,16 @@ fun formatUptime(seconds: Int): String { .joinToString(" ") } } + +/** + * Calculates the remaining mute time in days and hours. + * + * @param remainingMillis The remaining time in milliseconds + * @return Pair of (days, hours), where days is Int and hours is Double + */ +fun formatMuteRemainingTime(remainingMillis: Long): Pair { + val duration = remainingMillis.milliseconds + if (duration <= kotlin.time.Duration.ZERO) return 0 to 0.0 + val totalHours = duration.toDouble(kotlin.time.DurationUnit.HOURS) + return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt index ea7e37340..3421c4517 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.model.util import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.getSystemMeasurementSystem import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits @@ -49,12 +50,15 @@ fun Int.metersIn(system: DisplayUnits): Float { return this.metersIn(unit) } -fun Float.toString(unit: DistanceUnit): String = if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) { - "%.0f %s" -} else { - "%.1f %s" +fun Float.toString(unit: DistanceUnit): String { + val pattern = + if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) { + "%.0f %s" + } else { + "%.1f %s" + } + return formatString(pattern, this, unit.symbol) } - .format(this, unit.symbol) fun Float.toString(system: DisplayUnits): String { val unit = @@ -81,14 +85,14 @@ fun Int.toDistanceString(system: DisplayUnits): String { @Suppress("MagicNumber") fun Float.toSpeedString(system: DisplayUnits): String = if (system == DisplayUnits.METRIC) { - "%.0f km/h".format(this * 3.6) + formatString("%.0f km/h", this * 3.6) } else { - "%.0f mph".format(this * 2.23694f) + formatString("%.0f mph", this * 2.23694f) } @Suppress("MagicNumber") fun Float.toSmallDistanceString(system: DisplayUnits): String = if (system == DisplayUnits.IMPERIAL) { - "%.2f in".format(this / 25.4f) + formatString("%.2f in", this / 25.4f) } else { - "%.0f mm".format(this) + formatString("%.0f mm", this) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt index 4ab635a6d..b2e175382 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt @@ -62,7 +62,7 @@ private fun decodeSharedContactData(data: String): SharedContact { sanitized.decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string") } catch (e: IllegalArgumentException) { throw MalformedMeshtasticUrlException( - "Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}", + "Failed to Base64 decode SharedContact data ($data): ${e::class.simpleName}: ${e.message}", ) } @@ -70,7 +70,7 @@ private fun decodeSharedContactData(data: String): SharedContact { SharedContact.ADAPTER.decode(decodedBytes) } catch (e: Exception) { throw MalformedMeshtasticUrlException( - "Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}", + "Failed to proto decode SharedContact: ${e::class.simpleName}: ${e.message}", ) } } diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt new file mode 100644 index 000000000..7545a00a7 --- /dev/null +++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +/** No-op stubs for core:model on iOS. */ +actual fun getShortDateTime(time: Long): String = "" + +actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size) + +actual object SfppHasher { + actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32) +} diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index a397ce986..472fdf023 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -22,8 +22,6 @@ plugins { } kotlin { - jvm() - android { namespace = "org.meshtastic.core.navigation" } sourceSets { diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt new file mode 100644 index 000000000..fe5c6225a --- /dev/null +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.navigation + +import androidx.navigation3.runtime.NavKey +import androidx.savedstate.serialization.SavedStateConfiguration +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +/** + * Shared polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used + * across Android and Desktop navigation graphs. + */ +val MeshtasticNavSavedStateConfig = SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + // Nodes + subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer()) + subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer()) + subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer()) + subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer()) + + // Node detail sub-screens + subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer()) + subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer()) + subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer()) + subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer()) + subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer()) + subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer()) + subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer()) + subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer()) + subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer()) + subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer()) + subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer()) + + // Conversations + subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer()) + subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer()) + subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer()) + subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer()) + subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer()) + + // Map + subclass(MapRoutes.Map::class, MapRoutes.Map.serializer()) + + // Firmware + subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer()) + subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer()) + + // Settings + subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer()) + subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer()) + subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer()) + subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer()) + subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer()) + + // Settings - Config routes + subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer()) + subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer()) + subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer()) + subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer()) + subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer()) + subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer()) + subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer()) + subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer()) + subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer()) + subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer()) + + // Settings - Module routes + subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer()) + subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer()) + subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer()) + subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer()) + subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer()) + subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer()) + subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer()) + subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer()) + subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer()) + subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer()) + subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer()) + subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer()) + subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer()) + subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer()) + subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer()) + subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer()) + + // Settings - Advanced routes + subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer()) + subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer()) + subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer()) + subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer()) + + // Channels + subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer()) + subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer()) + + // Connections + subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer()) + subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer()) + } + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 1e086af6a..9e7e4f8df 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -23,8 +23,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.core.network" diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt index 8de3000af..5a5a1314a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt @@ -313,8 +313,8 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str user = User( id = DataPacket.nodeNumToDefaultId(numIn), - long_name = "Sim " + Integer.toHexString(numIn), - short_name = getInitials("Sim " + Integer.toHexString(numIn)), + long_name = "Sim " + numIn.toString(16), + short_name = getInitials("Sim " + numIn.toString(16)), hw_model = HardwareModel.ANDROID_SIM, ), position = diff --git a/core/nfc/build.gradle.kts b/core/nfc/build.gradle.kts index 559a96868..801bbf8f2 100644 --- a/core/nfc/build.gradle.kts +++ b/core/nfc/build.gradle.kts @@ -21,8 +21,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.core.nfc" diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index 431d3bb13..97f728e81 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -21,8 +21,6 @@ plugins { } kotlin { - jvm() - android { namespace = "org.meshtastic.core.prefs" androidResources.enable = false diff --git a/core/proto/build.gradle.kts b/core/proto/build.gradle.kts index 615074efc..e60195e19 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -24,9 +24,6 @@ plugins { apply(from = rootProject.file("gradle/publishing.gradle.kts")) kotlin { - // Keep jvm() for desktop/server consumers - jvm() - // Override minSdk for ATAK compatibility (standard is 26) android { minSdk = 21 } diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index a3cc369c7..0c9b7d8f8 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -21,8 +21,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { androidResources.enable = false } diff --git a/core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt b/core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt new file mode 100644 index 000000000..e7abe31bb --- /dev/null +++ b/core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +/** No-op stub for Location on iOS. */ +actual class Location diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index 7edce86b6..47d8c12e0 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -29,7 +29,10 @@ kotlin { withHostTest { isIncludeAndroidResources = true } } - sourceSets { commonTest.dependencies { implementation(kotlin("test")) } } + sourceSets { + commonMain.dependencies { implementation(projects.core.common) } + commonTest.dependencies { implementation(kotlin("test")) } + } } compose.resources { diff --git a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt index 54aa4760a..9557ce752 100644 --- a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt +++ b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt @@ -25,12 +25,22 @@ fun getString(stringResource: StringResource): String = runBlocking { composeGet /** Retrieves a formatted string from the [StringResource] in a blocking manner. */ fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { - val pattern = composeGetString(stringResource) - if (formatArgs.isNotEmpty()) { + val resolvedArgs = + formatArgs + .map { arg -> + if (arg is StringResource) { + composeGetString(arg) + } else { + arg + } + } + .toTypedArray() + + if (resolvedArgs.isNotEmpty()) { @Suppress("SpreadOperator") - pattern.format(*formatArgs) + composeGetString(stringResource, *resolvedArgs) } else { - pattern + composeGetString(stringResource) } } @@ -50,11 +60,10 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: } .toTypedArray() - val pattern = composeGetString(stringResource) return if (resolvedArgs.isNotEmpty()) { @Suppress("SpreadOperator") - pattern.format(*resolvedArgs) + composeGetString(stringResource, *resolvedArgs) } else { - pattern + composeGetString(stringResource) } } diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index dbffac9af..622f08eb1 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -21,8 +21,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.core.service" @@ -45,6 +43,7 @@ kotlin { implementation(projects.core.proto) implementation(libs.jetbrains.lifecycle.runtime) + implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.core) implementation(libs.kermit) } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 6132a8c39..b5a4617a9 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository @@ -100,7 +102,7 @@ class SharedRadioInterfaceService( private var radioIf: RadioTransport? = null private var isStarted = false - @Volatile private var listenersInitialized = false + private val listenersInitialized = kotlinx.atomicfu.atomic(false) private var heartbeatJob: kotlinx.coroutines.Job? = null private var lastHeartbeatMillis = 0L @@ -108,42 +110,46 @@ class SharedRadioInterfaceService( private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L } + private val initLock = Mutex() + private fun initStateListeners() { - if (listenersInitialized) return - synchronized(this) { - if (listenersInitialized) return - listenersInitialized = true + if (listenersInitialized.value) return + processLifecycle.coroutineScope.launch { + initLock.withLock { + if (listenersInitialized.value) return@withLock + listenersInitialized.value = true - radioPrefs.devAddr - .onEach { addr -> - if (_currentDeviceAddressFlow.value != addr) { - _currentDeviceAddressFlow.value = addr - startInterface() + radioPrefs.devAddr + .onEach { addr -> + if (_currentDeviceAddressFlow.value != addr) { + _currentDeviceAddressFlow.value = addr + startInterface() + } } - } - .launchIn(processLifecycle.coroutineScope) + .launchIn(processLifecycle.coroutineScope) - bluetoothRepository.state - .onEach { state -> - if (state.enabled) { - startInterface() - } else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) { - stopInterface() + bluetoothRepository.state + .onEach { state -> + if (state.enabled) { + startInterface() + } else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) { + stopInterface() + } } - } - .catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } } - .launchIn(processLifecycle.coroutineScope) + .catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } } + .launchIn(processLifecycle.coroutineScope) - networkRepository.networkAvailable - .onEach { state -> - if (state) { - startInterface() - } else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) { - stopInterface() + networkRepository.networkAvailable + .onEach { state -> + if (state) { + startInterface() + } else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) { + stopInterface() + } } - } - .catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } } - .launchIn(processLifecycle.coroutineScope) + .catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } } + .launchIn(processLifecycle.coroutineScope) + } } } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 8f8559af0..8e3c6f043 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -18,8 +18,6 @@ plugins { alias(libs.plugins.meshtastic.kmp.library) } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.core.testing" diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 8dc0af751..7ba2bdae3 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -23,8 +23,6 @@ plugins { } kotlin { - jvm() - android { namespace = "org.meshtastic.core.ui" androidResources.enable = false @@ -48,13 +46,15 @@ kotlin { implementation(libs.compose.multiplatform.materialIconsExtended) implementation(libs.compose.multiplatform.ui) implementation(libs.compose.multiplatform.foundation) - implementation(libs.compose.multiplatform.ui.tooling) + api(libs.compose.multiplatform.ui.tooling.preview) implementation(libs.kermit) implementation(libs.koin.compose.viewmodel) implementation(libs.qrcode.kotlin) } + val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } } + androidMain.dependencies { implementation(libs.androidx.activity.compose) } commonTest.dependencies { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index 16a5d5b34..22c6bfaf5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlin.jvm.JvmName @Composable fun > DropDownPreference( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt index 5752287ae..9f6a59d5f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt @@ -217,7 +217,7 @@ fun EditTextPreference( isError = isError, onValueChange = { if (maxSize > 0) { - if (it.toByteArray().size <= maxSize) { + if (it.encodeToByteArray().size <= maxSize) { onValueChanged(it) } } else { @@ -255,7 +255,7 @@ fun EditTextPreference( if (maxSize > 0 && isFocused) { Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.fillMaxWidth()) { Text( - text = "${value.toByteArray().size}/$maxSize", + text = "${value.encodeToByteArray().size}/$maxSize", style = MaterialTheme.typography.bodySmall, color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(end = 8.dp, bottom = 4.dp), diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index a818208a7..18992c0e7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bad import org.meshtastic.core.resources.fair @@ -153,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) { Text( modifier = modifier, - text = "%s %.2fdB".format(stringResource(Res.string.snr), snr), + text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr), color = color, style = MaterialTheme.typography.labelSmall, ) @@ -171,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) { } Text( modifier = modifier, - text = "%s %ddBm".format(stringResource(Res.string.rssi), rssi), + text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi), color = color, style = MaterialTheme.typography.labelSmall, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt index 4a4cc5ee8..4b64052e5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.icon.BatteryEmpty @@ -60,7 +61,7 @@ fun MaterialBatteryInfo( voltage: Float? = null, contentColor: Color = MaterialTheme.colorScheme.onSurface, ) { - val levelString = FORMAT.format(level) + val levelString = formatString(FORMAT, level) Row( modifier = modifier, @@ -130,7 +131,7 @@ fun MaterialBatteryInfo( ?.takeIf { it > 0 } ?.let { Text( - text = "%.2fV".format(it), + text = formatString("%.2fV", it), color = contentColor.copy(alpha = 0.8f), style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp), ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt index 5c28ce6e7..abd339888 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt @@ -16,6 +16,10 @@ */ package org.meshtastic.core.ui.component +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.MutableSharedFlow + /** * Event emitted when a user re-presses a bottom navigation destination that should trigger a scroll-to-top behaviour on * the corresponding screen. @@ -25,3 +29,5 @@ sealed class ScrollToTopEvent { data object ConversationsTabPressed : ScrollToTopEvent() } + +@Composable fun rememberScrollToTopEvents(): MutableSharedFlow = remember { MutableSharedFlow() } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index e8c964743..0a9c9b7e1 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.signal_quality @@ -63,7 +64,7 @@ fun SignalInfo( tint = signalColor, ) Text( - text = "%.1fdB · %ddBm · %s".format(node.snr, node.rssi, stringResource(quality.nameRes)), + text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)), style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index 7f64f18b5..632c8abb4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -43,7 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -297,7 +297,7 @@ fun ScannedQrCodeDialog( } } -@PreviewScreenSizes +@PreviewLightDark @Composable private fun ScannedQrCodeDialogPreview() { ScannedQrCodeDialog( diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt new file mode 100644 index 000000000..e18ffd84c --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberTimeTickWithLifecycle(): Long = 0L + +internal actual fun > enumEntriesOf(selectedItem: T): List = emptyList() + +internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt new file mode 100644 index 000000000..90010567f --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt new file mode 100644 index 000000000..4fd37a5ee --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLinkStyles +import org.jetbrains.compose.resources.StringResource + +actual fun createClipEntry(text: String, label: String): ClipEntry = + throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub") + +actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html) + +@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {} + +@Composable actual fun rememberShowToast(): suspend (String) -> Unit = { _ -> } + +@Composable actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -> } + +@Composable actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ -> } + +@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { _ -> } + +@Composable actual fun SetScreenBrightness(brightness: Float) {} diff --git a/desktop/README.md b/desktop/README.md index 8c47ca545..5e177a548 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -28,15 +28,15 @@ The module depends on the JVM variants of KMP modules: - `core:domain`, `core:data`, `core:database`, `core:datastore`, `core:prefs` - `core:network`, `core:resources`, `core:ui` -**Navigation:** Uses JetBrains multiplatform forks of Navigation 3 (`org.jetbrains.androidx.navigation3:navigation3-ui`) and Lifecycle (`org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`, `lifecycle-runtime-compose`). A `SavedStateConfiguration` with polymorphic `SerializersModule` is configured for non-Android NavKey serialization. Desktop shares route keys with Android via `core:navigation`, but graph wiring remains platform-specific; parity policy is tracked in [`docs/decisions/navigation3-parity-2026-03.md`](../docs/decisions/navigation3-parity-2026-03.md). +**Navigation:** Uses JetBrains multiplatform forks of Navigation 3 (`org.jetbrains.androidx.navigation3:navigation3-ui`) and Lifecycle (`org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`, `lifecycle-runtime-compose`). A unified `SavedStateConfiguration` with polymorphic `SerializersModule` is provided centrally by `core:navigation` for non-Android NavKey serialization. Desktop utilizes the exact same navigation graph wiring (`settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`) directly from the `commonMain` of their respective feature modules, maintaining full UI parity. **Coroutines:** Requires `kotlinx-coroutines-swing` for `Dispatchers.Main` on JVM/Desktop. Without it, any code using `lifecycle.coroutineScope` or `Dispatchers.Main` (e.g., `NodeRepositoryImpl`, `RadioConfigRepositoryImpl`) will crash at runtime. -**DI:** A Koin DI graph is bootstrapped in `Main.kt` with stub implementations for Android-only services. +**DI:** A Koin DI graph is bootstrapped in `Main.kt` with platform-specific implementations injected. -**UI:** JetBrains Compose for Desktop with Material 3 theming, sharing Compose components from `core:ui`. +**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules. -**Localization:** Desktop exposes a language picker in `ui/settings/DesktopSettingsScreen.kt`, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack. +**Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack. ## Key Files @@ -44,24 +44,11 @@ The module depends on the JVM variants of KMP modules: |---|---| | `Main.kt` | App entry point — Koin bootstrap, Compose Desktop window, theme + locale application | | `DemoScenario.kt` | Offline demo data for testing without a connected device | -| `ui/DesktopMainScreen.kt` | Navigation 3 shell — `NavigationRail` + `NavDisplay` + `SavedStateConfiguration` | -| `navigation/DesktopNavigation.kt` | Nav graph entry registrations for all top-level destinations | -| `navigation/DesktopSettingsNavigation.kt` | Real settings feature composables wired into nav graph (~35 screens) | -| `navigation/DesktopNodeNavigation.kt` | Real adaptive node list-detail + real metrics screens (logs + charts); map routes remain placeholders | -| `navigation/DesktopMessagingNavigation.kt` | Real adaptive contacts list-detail + real Messages/Share/QuickChat route screens | +| `ui/DesktopMainScreen.kt` | Navigation 3 shell — `NavigationRail` + `NavDisplay` | +| `navigation/DesktopNavigation.kt` | Nav graph entry registrations for all top-level destinations (delegates to shared feature graphs) | | `radio/DesktopRadioTransportFactory.kt` | Provides TCP, Serial/USB, and BLE transports | | `radio/DesktopMeshServiceController.kt` | Mesh service lifecycle — orchestrates `want_config` handshake chain | | `radio/DesktopMessageQueue.kt` | Message queue for outbound mesh packets | -| `ui/firmware/DesktopFirmwareScreen.kt` | Placeholder firmware screen (native DFU is Android-only) | -| `ui/settings/DesktopSettingsScreen.kt` | Desktop-specific top-level settings screen, including theme/language/app-info controls | -| `ui/settings/DesktopDeviceConfigScreen.kt` | Device config with JVM `ZoneId` timezone (replaces Android BroadcastReceiver) | -| `ui/settings/DesktopPositionConfigScreen.kt` | Position config without Android Location APIs | -| `ui/settings/DesktopNetworkConfigScreen.kt` | Network config without QR/NFC scanning | -| `ui/settings/DesktopSecurityConfigScreen.kt` | Security config with JVM `SecureRandom` (omits file export) | -| `ui/settings/DesktopExternalNotificationConfigScreen.kt` | External notification config without MediaPlayer/file import | -| `ui/nodes/DesktopAdaptiveNodeListScreen.kt` | Adaptive node list-detail using JetBrains `ListDetailPaneScaffold` | -| `ui/messaging/DesktopAdaptiveContactsScreen.kt` | Adaptive contacts list-detail using JetBrains `ListDetailPaneScaffold` | -| `ui/messaging/DesktopMessageContent.kt` | Desktop message content with send, reactions, and selection | | `di/DesktopKoinModule.kt` | Koin module with stub implementations | | `di/DesktopPlatformModule.kt` | Platform-specific Koin bindings | | `stub/NoopStubs.kt` | No-op implementations for all repository interfaces | diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 668b1145b..8bbc886af 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -144,6 +144,7 @@ dependencies { implementation(projects.feature.messaging) implementation(projects.feature.connections) implementation(projects.feature.map) + implementation(projects.feature.firmware) // Compose Desktop implementation(compose.desktop.currentOs) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 24b73cbc5..3b2585fe3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.first import org.koin.core.context.startKoin import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.service.MeshServiceOrchestrator @@ -57,7 +58,6 @@ import org.meshtastic.desktop.data.DesktopPreferencesDataSource import org.meshtastic.desktop.di.desktopModule import org.meshtastic.desktop.di.desktopPlatformModule import org.meshtastic.desktop.ui.DesktopMainScreen -import org.meshtastic.desktop.ui.navSavedStateConfig import java.awt.Desktop import java.util.Locale @@ -199,7 +199,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { state = windowState, ) { val backStack = - rememberNavBackStack(navSavedStateConfig, TopLevelDestination.Connections.route as NavKey) + rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey) MenuBar { Menu("File") { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt deleted file mode 100644 index cc0f19c09..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.navigation - -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.ChannelsRoutes -import org.meshtastic.core.navigation.ContactsRoutes -import org.meshtastic.desktop.ui.messaging.DesktopAdaptiveContactsScreen -import org.meshtastic.desktop.ui.messaging.DesktopMessageContent -import org.meshtastic.feature.messaging.MessageViewModel -import org.meshtastic.feature.messaging.QuickChatScreen -import org.meshtastic.feature.messaging.QuickChatViewModel -import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel -import org.meshtastic.feature.messaging.ui.sharing.ShareScreen - -/** - * Registers real messaging/contacts feature composables into the desktop navigation graph. - * - * The contacts screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding, - * backed by shared `ContactsViewModel` from commonMain. The list pane shows contacts and the detail pane shows - * `DesktopMessageContent` using shared `MessageViewModel` with a non-paged message list. - */ -fun EntryProviderScope.desktopMessagingGraph(backStack: NavBackStack) { - entry { - val viewModel: ContactsViewModel = koinViewModel() - DesktopAdaptiveContactsScreen( - viewModel = viewModel, - onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, - ) - } - - entry { - val viewModel: ContactsViewModel = koinViewModel() - DesktopAdaptiveContactsScreen( - viewModel = viewModel, - onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, - ) - } - - entry { route -> - val viewModel: MessageViewModel = koinViewModel(key = "messages-${route.contactKey}") - DesktopMessageContent( - contactKey = route.contactKey, - viewModel = viewModel, - initialMessage = route.message, - onNavigateUp = { backStack.removeLastOrNull() }, - ) - } - - entry { route -> - val viewModel: ContactsViewModel = koinViewModel() - ShareScreen( - viewModel = viewModel, - onConfirm = { contactKey -> - backStack.removeLastOrNull() - backStack.add(ContactsRoutes.Messages(contactKey, route.message)) - }, - onNavigateUp = { backStack.removeLastOrNull() }, - ) - } - - entry { - val viewModel: QuickChatViewModel = koinViewModel() - QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index 5ec4d35f7..c32eae750 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -26,57 +26,47 @@ import androidx.compose.ui.Modifier import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import org.meshtastic.core.navigation.ConnectionsRoutes -import org.meshtastic.core.navigation.FirmwareRoutes -import org.meshtastic.core.navigation.MapRoutes -import org.meshtastic.desktop.ui.firmware.DesktopFirmwareScreen import org.meshtastic.desktop.ui.map.KmpMapPlaceholder -import org.meshtastic.feature.connections.ui.ConnectionsScreen +import org.meshtastic.feature.connections.navigation.connectionsGraph +import org.meshtastic.feature.firmware.navigation.firmwareGraph +import org.meshtastic.feature.map.navigation.mapGraph +import org.meshtastic.feature.messaging.navigation.contactsGraph +import org.meshtastic.feature.node.navigation.nodesGraph +import org.meshtastic.feature.settings.navigation.settingsGraph import org.meshtastic.feature.settings.radio.channel.channelsGraph /** * Registers entry providers for all top-level desktop destinations. * - * Nodes uses real composables from `feature:node` via [desktopNodeGraph]. Conversations uses real composables from + * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via - * [desktopSettingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until - * their shared composables are wired. + * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their + * shared composables are wired. */ fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack) { // Nodes — real composables from feature:node - desktopNodeGraph(backStack) + nodesGraph( + backStack = backStack, + nodeMapScreen = { destNum, _ -> KmpMapPlaceholder(title = "Node Map ($destNum)") }, + ) // Conversations — real composables from feature:messaging - desktopMessagingGraph(backStack) + contactsGraph(backStack) // Map — placeholder for now, will be replaced with feature:map real implementation - entry { KmpMapPlaceholder() } + mapGraph(backStack) // Firmware — in-flow destination (for example from Settings), not a top-level rail tab - entry { DesktopFirmwareScreen() } - entry { DesktopFirmwareScreen() } + firmwareGraph(backStack) // Settings — real composables from feature:settings - desktopSettingsGraph(backStack) + settingsGraph(backStack) // Channels channelsGraph(backStack) // Connections — shared screen - entry { - ConnectionsScreen( - onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) }, - onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) }, - onConfigNavigate = { route -> backStack.add(route) }, - ) - } - entry { - ConnectionsScreen( - onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) }, - onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) }, - onConfigNavigate = { route -> backStack.add(route) }, - ) - } + connectionsGraph(backStack) } @Composable diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt deleted file mode 100644 index 42b6ded59..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import org.meshtastic.core.navigation.NodeDetailRoutes -import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.desktop.ui.map.KmpMapPlaceholder -import org.meshtastic.desktop.ui.nodes.DesktopAdaptiveNodeListScreen -import org.meshtastic.feature.node.list.NodeListViewModel -import org.meshtastic.feature.node.metrics.DeviceMetricsScreen -import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen -import org.meshtastic.feature.node.metrics.HostMetricsLogScreen -import org.meshtastic.feature.node.metrics.MetricsViewModel -import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen -import org.meshtastic.feature.node.metrics.PaxMetricsScreen -import org.meshtastic.feature.node.metrics.PowerMetricsScreen -import org.meshtastic.feature.node.metrics.SignalMetricsScreen -import org.meshtastic.feature.node.metrics.TracerouteLogScreen - -/** - * Registers real node feature composables into the desktop navigation graph. - * - * The node list screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding, - * backed by shared `NodeListViewModel` and commonMain components. The detail pane shows real shared node detail content - * from commonMain. - * - * Metrics screens (logs + chart-based detail metrics) use shared composables from commonMain with `MetricsViewModel` - * scoped to the destination node number. - */ -fun EntryProviderScope.desktopNodeGraph(backStack: NavBackStack) { - entry { - val viewModel: NodeListViewModel = koinViewModel() - DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) }) - } - - entry { - val viewModel: NodeListViewModel = koinViewModel() - DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) }) - } - - // Node detail graph routes open the real shared list-detail screen focused on the requested node. - entry { route -> - val viewModel: NodeListViewModel = koinViewModel() - DesktopAdaptiveNodeListScreen( - viewModel = viewModel, - initialNodeId = route.destNum, - onNavigate = { backStack.add(it) }, - ) - } - - entry { route -> - val viewModel: NodeListViewModel = koinViewModel() - DesktopAdaptiveNodeListScreen( - viewModel = viewModel, - initialNodeId = route.destNum, - onNavigate = { backStack.add(it) }, - ) - } - - // Traceroute log — real shared screen from commonMain - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - TracerouteLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - - // Neighbor info log — real shared screen from commonMain - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - NeighborInfoLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - - // Host metrics log — real shared screen from commonMain - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - HostMetricsLogScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - - // Chart-based metrics — real shared screens from commonMain - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - DeviceMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - EnvironmentMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - SignalMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - PowerMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel -> - PaxMetricsScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) - } - - // Map-based screens — placeholders (map integration needed) - entry { route -> KmpMapPlaceholder(title = "Node Map (${route.destNum})") } - entry { KmpMapPlaceholder(title = "Traceroute Map") } - entry { route -> KmpMapPlaceholder(title = "Position Log (${route.destNum})") } -} - -private inline fun EntryProviderScope.desktopMetricsEntry( - crossinline getDestNum: (R) -> Int, - crossinline content: @Composable (MetricsViewModel) -> Unit, -) { - entry { route -> - val destNum = getDestNum(route) - val viewModel: MetricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) } - LaunchedEffect(destNum) { viewModel.setNodeId(destNum) } - content(viewModel) - } -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt deleted file mode 100644 index aab9ea8e5..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes -import org.meshtastic.desktop.ui.settings.DesktopDeviceConfigScreen -import org.meshtastic.desktop.ui.settings.DesktopExternalNotificationConfigScreen -import org.meshtastic.desktop.ui.settings.DesktopNetworkConfigScreen -import org.meshtastic.desktop.ui.settings.DesktopPositionConfigScreen -import org.meshtastic.desktop.ui.settings.DesktopSecurityConfigScreen -import org.meshtastic.desktop.ui.settings.DesktopSettingsScreen -import org.meshtastic.feature.settings.AboutScreen -import org.meshtastic.feature.settings.AdministrationScreen -import org.meshtastic.feature.settings.DeviceConfigurationScreen -import org.meshtastic.feature.settings.ModuleConfigurationScreen -import org.meshtastic.feature.settings.SettingsViewModel -import org.meshtastic.feature.settings.filter.FilterSettingsScreen -import org.meshtastic.feature.settings.filter.FilterSettingsViewModel -import org.meshtastic.feature.settings.navigation.ConfigRoute -import org.meshtastic.feature.settings.navigation.ModuleRoute -import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen -import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel -import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen -import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen -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.DisplayConfigScreen -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.PaxcounterConfigScreen -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.SerialConfigScreen -import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen -import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen -import org.meshtastic.feature.settings.radio.component.TAKConfigScreen -import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen -import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen -import org.meshtastic.feature.settings.radio.component.UserConfigScreen -import kotlin.reflect.KClass - -@Composable -private fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewModel { - val viewModel = koinViewModel() - LaunchedEffect(backStack) { - val destNum = - backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum } - ?: backStack - .lastOrNull { it is SettingsRoutes.SettingsGraph } - ?.let { (it as SettingsRoutes.SettingsGraph).destNum } - viewModel.initDestNum(destNum) - } - return viewModel -} - -/** - * Registers real settings feature composables into the desktop navigation graph. - * - * Top-level settings screen is a desktop-specific composable since Android's [SettingsScreen] uses Android-only APIs. - * All sub-screens (device config, module config, radio config, channels, etc.) use the shared commonMain composables - * from `feature:settings`. - */ -@Suppress("LongMethod", "CyclomaticComplexMethod") -fun EntryProviderScope.desktopSettingsGraph(backStack: NavBackStack) { - // Top-level settings — desktop-specific screen (Android version uses Activity, permissions, etc.) - entry { - DesktopSettingsScreen( - radioConfigViewModel = getRadioConfigViewModel(backStack), - settingsViewModel = koinViewModel(), - onNavigate = { route -> backStack.add(route) }, - ) - } - - entry { - DesktopSettingsScreen( - radioConfigViewModel = getRadioConfigViewModel(backStack), - settingsViewModel = koinViewModel(), - onNavigate = { route -> backStack.add(route) }, - ) - } - - // Device configuration — shared commonMain composable - entry { - DeviceConfigurationScreen( - viewModel = getRadioConfigViewModel(backStack), - onBack = { backStack.removeLastOrNull() }, - onNavigate = { route -> backStack.add(route) }, - ) - } - - // Module configuration — shared commonMain composable - entry { - val settingsViewModel: SettingsViewModel = koinViewModel() - val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() - ModuleConfigurationScreen( - viewModel = getRadioConfigViewModel(backStack), - excludedModulesUnlocked = excludedModulesUnlocked, - onBack = { backStack.removeLastOrNull() }, - onNavigate = { route -> backStack.add(route) }, - ) - } - - // Administration — shared commonMain composable - entry { - AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() }) - } - - // Clean node database — shared commonMain composable - entry { - val viewModel: CleanNodeDatabaseViewModel = koinViewModel() - CleanNodeDatabaseScreen(viewModel = viewModel) - } - - // Debug Panel — shared commonMain composable - entry { - val viewModel: org.meshtastic.feature.settings.debugging.DebugViewModel = koinViewModel() - org.meshtastic.feature.settings.debugging.DebugScreen( - viewModel = viewModel, - onNavigateUp = { backStack.removeLastOrNull() }, - ) - } - - // Config routes — all from commonMain composables - ConfigRoute.entries.forEach { routeInfo -> - desktopConfigComposable(routeInfo.route::class, backStack) { viewModel -> - LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } - when (routeInfo) { - ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DEVICE -> DesktopDeviceConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POSITION -> - DesktopPositionConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.NETWORK -> DesktopNetworkConfigScreen(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 -> - DesktopSecurityConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - } - } - } - - // Module routes — all from commonMain composables - ModuleRoute.entries.forEach { routeInfo -> - desktopConfigComposable(routeInfo.route::class, backStack) { viewModel -> - LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } - when (routeInfo) { - ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.EXT_NOTIFICATION -> - DesktopExternalNotificationConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.STORE_FORWARD -> - StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.CANNED_MESSAGE -> - CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.REMOTE_HARDWARE -> - RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.NEIGHBOR_INFO -> - NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.AMBIENT_LIGHTING -> - AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.DETECTION_SENSOR -> - DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.STATUS_MESSAGE -> - StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TRAFFIC_MANAGEMENT -> - TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - } - } - } - - // About — shared commonMain screen, per-platform library definitions loaded from JVM classpath - entry { - AboutScreen( - onNavigateUp = { backStack.removeLastOrNull() }, - jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" }, - ) - } - - // Filter settings — shared commonMain composable - entry { - val viewModel: FilterSettingsViewModel = koinViewModel() - FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() }) - } -} - -/** Helper to register a config/module route entry with a [RadioConfigViewModel] scoped to that entry. */ -fun EntryProviderScope.desktopConfigComposable( - route: KClass, - backStack: NavBackStack, - content: @Composable (RadioConfigViewModel) -> Unit, -) { - addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) } -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 26ff14e5a..f5224f63d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -32,22 +32,11 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay -import androidx.savedstate.serialization.SavedStateConfiguration -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.navigation.ChannelsRoutes -import org.meshtastic.core.navigation.ConnectionsRoutes -import org.meshtastic.core.navigation.ContactsRoutes -import org.meshtastic.core.navigation.FirmwareRoutes -import org.meshtastic.core.navigation.MapRoutes -import org.meshtastic.core.navigation.NodeDetailRoutes -import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.ui.navigation.icon @@ -56,90 +45,6 @@ import org.meshtastic.core.ui.share.SharedContactDialog import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.navigation.desktopNavGraph -/** - * Polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used in the - * desktop navigation graph. - */ -internal val navSavedStateConfig = SavedStateConfiguration { - serializersModule = SerializersModule { - polymorphic(NavKey::class) { - // Nodes - subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer()) - subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer()) - subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer()) - subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer()) - // Node detail sub-screens - subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer()) - subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer()) - subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer()) - subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer()) - subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer()) - subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer()) - subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer()) - subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer()) - subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer()) - subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer()) - subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer()) - // Conversations - subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer()) - subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer()) - subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer()) - subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer()) - subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer()) - // Map - subclass(MapRoutes.Map::class, MapRoutes.Map.serializer()) - // Firmware - subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer()) - subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer()) - // Settings - subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer()) - subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer()) - subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer()) - subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer()) - subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer()) - // Settings - Config routes - subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer()) - subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer()) - subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer()) - subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer()) - subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer()) - subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer()) - subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer()) - subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer()) - subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer()) - subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer()) - // Settings - Module routes - subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer()) - subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer()) - subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer()) - subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer()) - subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer()) - subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer()) - subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer()) - subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer()) - subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer()) - subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer()) - subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer()) - subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer()) - subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer()) - subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer()) - subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer()) - subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer()) - // Settings - Advanced routes - subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer()) - subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer()) - subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer()) - subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer()) - // Channels - subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer()) - subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer()) - // Connections - subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer()) - subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer()) - } - } -} - /** * Desktop main screen — Navigation 3 shell with a persistent [NavigationRail] and [NavDisplay]. * diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt deleted file mode 100644 index f6a3433f0..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.ui.messaging - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -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.rememberListDetailPaneScaffoldNavigator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.conversations -import org.meshtastic.core.resources.mark_as_read -import org.meshtastic.core.resources.unread_count -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.MeshtasticImportFAB -import org.meshtastic.core.ui.icon.MarkChatRead -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.viewmodel.UIViewModel -import org.meshtastic.feature.messaging.MessageViewModel -import org.meshtastic.feature.messaging.component.EmptyConversationsPlaceholder -import org.meshtastic.feature.messaging.ui.contact.ContactItem -import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel - -/** - * Desktop adaptive contacts screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive. - * - * On wide screens, the contacts list is shown on the left and the selected conversation detail on the right. On narrow - * screens, the scaffold automatically switches to a single-pane layout. - * - * Uses the shared [ContactsViewModel] and [ContactItem] from commonMain. The detail pane shows [DesktopMessageContent] - * with a non-paged message list and send input, backed by the shared [MessageViewModel]. - */ -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Suppress("LongMethod") -@Composable -fun DesktopAdaptiveContactsScreen( - viewModel: ContactsViewModel, - onNavigateToShareChannels: () -> Unit = {}, - uiViewModel: UIViewModel = koinViewModel(), -) { - val contacts by viewModel.contactList.collectAsStateWithLifecycle() - val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val unreadTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle() - val navigator = rememberListDetailPaneScaffoldNavigator() - val scope = rememberCoroutineScope() - - val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - listPane = { - AnimatedPane { - Scaffold( - topBar = { - MainAppBar( - title = stringResource(Res.string.conversations), - subtitle = - if (unreadTotal > 0) { - stringResource(Res.string.unread_count, unreadTotal) - } else { - null - }, - ourNode = ourNode, - showNodeChip = false, - canNavigateUp = false, - onNavigateUp = {}, - actions = { - if (unreadTotal > 0) { - IconButton(onClick = { viewModel.markAllAsRead() }) { - Icon( - MeshtasticIcons.MarkChatRead, - contentDescription = stringResource(Res.string.mark_as_read), - ) - } - } - }, - onClickChip = {}, - ) - }, - floatingActionButton = { - if (connectionState == ConnectionState.Connected) { - MeshtasticImportFAB( - onImport = { uriString -> - uiViewModel.handleScannedUri( - org.meshtastic.core.common.util.MeshtasticUri(uriString), - ) { - // OnInvalid - } - }, - onShareChannels = onNavigateToShareChannels, - sharedContact = sharedContactRequested, - onDismissSharedContact = { uiViewModel.clearSharedContactRequested() }, - isContactContext = true, - ) - } - }, - ) { contentPadding -> - if (contacts.isEmpty()) { - EmptyConversationsPlaceholder(modifier = Modifier.padding(contentPadding)) - } else { - LazyColumn(modifier = Modifier.fillMaxSize().padding(contentPadding)) { - items(contacts, key = { it.contactKey }) { contact -> - val isActive = navigator.currentDestination?.contentKey == contact.contactKey - ContactItem( - contact = contact, - selected = false, - isActive = isActive, - onClick = { - scope.launch { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contact.contactKey) - } - }, - ) - } - item { Spacer(modifier = Modifier.height(16.dp)) } - } - } - } - } - }, - detailPane = { - AnimatedPane { - navigator.currentDestination?.contentKey?.let { contactKey -> - val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey") - DesktopMessageContent(contactKey = contactKey, viewModel = messageViewModel) - } ?: EmptyConversationsPlaceholder(modifier = Modifier) - } - }, - ) -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt deleted file mode 100644 index 8a2b50a3a..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt +++ /dev/null @@ -1,507 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.ui.messaging - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.isShiftPressed -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.getChannel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.no_messages_yet -import org.meshtastic.core.resources.unknown_channel -import org.meshtastic.core.ui.component.EmptyDetailPlaceholder -import org.meshtastic.core.ui.icon.Conversations -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.createClipEntry -import org.meshtastic.feature.messaging.MessageViewModel -import org.meshtastic.feature.messaging.component.ActionModeTopBar -import org.meshtastic.feature.messaging.component.DeleteMessageDialog -import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES -import org.meshtastic.feature.messaging.component.MessageInput -import org.meshtastic.feature.messaging.component.MessageItem -import org.meshtastic.feature.messaging.component.MessageMenuAction -import org.meshtastic.feature.messaging.component.MessageStatusDialog -import org.meshtastic.feature.messaging.component.MessageTopBar -import org.meshtastic.feature.messaging.component.QuickChatRow -import org.meshtastic.feature.messaging.component.ReplySnippet -import org.meshtastic.feature.messaging.component.ScrollToBottomFab -import org.meshtastic.feature.messaging.component.UnreadMessagesDivider -import org.meshtastic.feature.messaging.component.handleQuickChatAction - -/** - * Desktop message content view for the contacts detail pane. - * - * Uses a non-paged [LazyColumn] to display messages for a selected conversation. Now shares the full message screen - * component set with Android, including: proper reply-to-message with replyId, message selection mode, quick chat row, - * message filtering, delivery info dialog, overflow menu, byte counter input, and unread dividers. - * - * The only difference from Android is the non-paged data source (Flow> vs LazyPagingItems) and the - * absence of PredictiveBackHandler. - */ -@Suppress("LongMethod", "CyclomaticComplexMethod") -@Composable -fun DesktopMessageContent( - contactKey: String, - viewModel: MessageViewModel, - modifier: Modifier = Modifier, - initialMessage: String = "", - onNavigateUp: (() -> Unit)? = null, -) { - val coroutineScope = rememberCoroutineScope() - val clipboardManager = LocalClipboard.current - - val nodes by viewModel.nodeList.collectAsStateWithLifecycle() - val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val channels by viewModel.channels.collectAsStateWithLifecycle() - val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList()) - val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap()) - val homoglyphEncodingEnabled by viewModel.homoglyphEncodingEnabled.collectAsStateWithLifecycle(initialValue = false) - - val messages by viewModel.getMessagesFlow(contactKey).collectAsStateWithLifecycle(initialValue = emptyList()) - - // UI State - var replyingToPacketId by rememberSaveable { mutableStateOf(null) } - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } - val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet()) } - var messageText by rememberSaveable(contactKey) { mutableStateOf(initialMessage) } - val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle() - val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle() - val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle() - val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false - - var showStatusDialog by remember { mutableStateOf(null) } - val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } } - - val listState = rememberLazyListState() - val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle() - - // Derive title - val channelInfo = - remember(contactKey, channels) { - val index = contactKey.firstOrNull()?.digitToIntOrNull() - val id = contactKey.substring(1) - val name = index?.let { channels.getChannel(it)?.name } - Triple(index, id, name) - } - val (channelIndex, nodeId, rawChannelName) = channelInfo - val unknownChannelText = stringResource(Res.string.unknown_channel) - val channelName = rawChannelName ?: unknownChannelText - - val title = - remember(nodeId, channelName, viewModel) { - when (nodeId) { - DataPacket.ID_BROADCAST -> channelName - else -> viewModel.getUser(nodeId).long_name - } - } - - val isMismatchKey = - remember(channelIndex, nodeId, viewModel) { - channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey - } - - // Find the original message for reply snippet - val originalMessage by - remember(replyingToPacketId, messages.size) { - derivedStateOf { replyingToPacketId?.let { id -> messages.firstOrNull { it.packetId == id } } } - } - - // Scroll to bottom when new messages arrive and we're already at the bottom - LaunchedEffect(messages.size) { - if (messages.isNotEmpty() && !listState.canScrollBackward) { - listState.animateScrollToItem(0) - } - } - - // Seed route-provided draft text - LaunchedEffect(contactKey, initialMessage) { - if (initialMessage.isNotBlank() && messageText.isBlank()) { - messageText = initialMessage - } - } - - // Mark messages as read when they become visible - @OptIn(kotlinx.coroutines.FlowPreview::class) - LaunchedEffect(messages.size) { - snapshotFlow { if (listState.isScrollInProgress) null else listState.layoutInfo } - .debounce(SCROLL_SETTLE_MILLIS) - .collectLatest { layoutInfo -> - if (layoutInfo == null || messages.isEmpty()) return@collectLatest - - val visibleItems = layoutInfo.visibleItemsInfo - if (visibleItems.isEmpty()) return@collectLatest - - val topVisibleIndex = visibleItems.first().index - val bottomVisibleIndex = visibleItems.last().index - - val firstVisibleUnread = - (bottomVisibleIndex..topVisibleIndex) - .mapNotNull { if (it in messages.indices) messages[it] else null } - .firstOrNull { !it.fromLocal && !it.read } - - firstVisibleUnread?.let { message -> - viewModel.clearUnreadCount(contactKey, message.uuid, message.receivedTime) - } - } - } - - // Dialogs - if (showDeleteDialog) { - DeleteMessageDialog( - count = selectedMessageIds.value.size, - onConfirm = { - viewModel.deleteMessages(selectedMessageIds.value.toList()) - selectedMessageIds.value = emptySet() - showDeleteDialog = false - }, - onDismiss = { showDeleteDialog = false }, - ) - } - - showStatusDialog?.let { message -> - MessageStatusDialog( - message = message, - nodes = nodes, - ourNode = ourNode, - resendOption = message.status?.equals(MessageStatus.ERROR) ?: false, - onResend = { - viewModel.deleteMessages(listOf(message.uuid)) - viewModel.sendMessage(message.text, contactKey) - showStatusDialog = null - }, - onDismiss = { showStatusDialog = null }, - ) - } - - Scaffold( - modifier = modifier, - topBar = { - if (inSelectionMode) { - ActionModeTopBar( - selectedCount = selectedMessageIds.value.size, - onAction = { action -> - when (action) { - MessageMenuAction.ClipboardCopy -> { - val copiedText = - messages - .filter { it.uuid in selectedMessageIds.value } - .joinToString("\n") { it.text } - coroutineScope.launch { - clipboardManager.setClipEntry(createClipEntry(copiedText, "messages")) - } - selectedMessageIds.value = emptySet() - } - - MessageMenuAction.Delete -> showDeleteDialog = true - MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet() - MessageMenuAction.SelectAll -> { - selectedMessageIds.value = - if (selectedMessageIds.value.size == messages.size) { - emptySet() - } else { - messages.map { it.uuid }.toSet() - } - } - } - }, - ) - } else { - MessageTopBar( - title = title, - channelIndex = channelIndex, - mismatchKey = isMismatchKey, - onNavigateBack = { onNavigateUp?.invoke() }, - channels = channels, - channelIndexParam = channelIndex, - showQuickChat = showQuickChat, - onToggleQuickChat = viewModel::toggleShowQuickChat, - filteringDisabled = filteringDisabled, - onToggleFilteringDisabled = { - viewModel.setContactFilteringDisabled(contactKey, !filteringDisabled) - }, - filteredCount = filteredCount, - showFiltered = showFiltered, - onToggleShowFiltered = viewModel::toggleShowFiltered, - ) - } - }, - bottomBar = { - Column { - AnimatedVisibility(visible = showQuickChat) { - QuickChatRow( - enabled = connectionState.isConnected(), - actions = quickChatActions, - onClick = { action -> - handleQuickChatAction( - action = action, - currentText = messageText, - onUpdateText = { messageText = it }, - onSendMessage = { text -> viewModel.sendMessage(text, contactKey) }, - ) - }, - ) - } - ReplySnippet( - originalMessage = originalMessage, - onClearReply = { replyingToPacketId = null }, - ourNode = ourNode, - ) - MessageInput( - messageText = messageText, - onMessageChange = { messageText = it }, - onSendMessage = { - val trimmed = messageText.trim() - if (trimmed.isNotEmpty()) { - viewModel.sendMessage(trimmed, contactKey, replyingToPacketId) - if (replyingToPacketId != null) replyingToPacketId = null - messageText = "" - } - }, - isEnabled = connectionState.isConnected(), - isHomoglyphEncodingEnabled = homoglyphEncodingEnabled, - modifier = - Modifier.onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) { - val currentByteLength = messageText.encodeToByteArray().size - val isOverLimit = currentByteLength > MESSAGE_CHARACTER_LIMIT_BYTES - val trimmed = messageText.trim() - if (trimmed.isNotEmpty() && connectionState.isConnected() && !isOverLimit) { - viewModel.sendMessage(trimmed, contactKey, replyingToPacketId) - if (replyingToPacketId != null) replyingToPacketId = null - messageText = "" - return@onPreviewKeyEvent true - } - // If over limit or empty, we still consume Enter to prevent newlines if the user - // intended to send, but only if they are not holding shift. - if (!event.isShiftPressed) return@onPreviewKeyEvent true - } - false - }, - ) - } - }, - ) { contentPadding -> - Box(Modifier.fillMaxSize().padding(contentPadding).focusable()) { - if (messages.isEmpty()) { - EmptyDetailPlaceholder( - icon = MeshtasticIcons.Conversations, - title = stringResource(Res.string.no_messages_yet), - ) - } else { - // Pre-calculate node map for O(1) lookup - val nodeMap = remember(nodes) { nodes.associateBy { it.num } } - - // Find first unread index - val firstUnreadIndex by - remember(messages.size) { - derivedStateOf { messages.indexOfFirst { !it.fromLocal && !it.read }.takeIf { it != -1 } } - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - reverseLayout = true, - contentPadding = PaddingValues(bottom = 24.dp, top = 24.dp), - ) { - items(messages.size, key = { messages[it].uuid }) { index -> - val message = messages[index] - val isSender = message.fromLocal - - // Because reverseLayout = true, visually previous (above) is index + 1 - val visuallyPrevMessage = if (index < messages.size - 1) messages[index + 1] else null - val visuallyNextMessage = if (index > 0) messages[index - 1] else null - - val hasSamePrev = - if (visuallyPrevMessage != null) { - visuallyPrevMessage.fromLocal == message.fromLocal && - (message.fromLocal || visuallyPrevMessage.node.num == message.node.num) - } else { - false - } - - val hasSameNext = - if (visuallyNextMessage != null) { - visuallyNextMessage.fromLocal == message.fromLocal && - (message.fromLocal || visuallyNextMessage.node.num == message.node.num) - } else { - false - } - - val isFirstUnread = firstUnreadIndex == index - val selected by - remember(message.uuid, selectedMessageIds.value) { - derivedStateOf { selectedMessageIds.value.contains(message.uuid) } - } - val node = nodeMap[message.node.num] ?: message.node - - if (isFirstUnread) { - Column { - UnreadMessagesDivider() - DesktopMessageItemRow( - message = message, - node = node, - ourNode = ourNode ?: Node(num = 0), - selected = selected, - inSelectionMode = inSelectionMode, - selectedMessageIds = selectedMessageIds, - contactKey = contactKey, - viewModel = viewModel, - listState = listState, - messages = messages, - onShowStatusDialog = { showStatusDialog = it }, - onReply = { replyingToPacketId = it?.packetId }, - hasSamePrev = hasSamePrev, - hasSameNext = hasSameNext, - showUserName = !isSender && !hasSamePrev, - quickEmojis = viewModel.frequentEmojis, - ) - } - } else { - DesktopMessageItemRow( - message = message, - node = node, - ourNode = ourNode ?: Node(num = 0), - selected = selected, - inSelectionMode = inSelectionMode, - selectedMessageIds = selectedMessageIds, - contactKey = contactKey, - viewModel = viewModel, - listState = listState, - messages = messages, - onShowStatusDialog = { showStatusDialog = it }, - onReply = { replyingToPacketId = it?.packetId }, - hasSamePrev = hasSamePrev, - hasSameNext = hasSameNext, - showUserName = !isSender && !hasSamePrev, - quickEmojis = viewModel.frequentEmojis, - ) - } - } - } - } - - // Show FAB if we can scroll towards the newest messages (index 0). - if (listState.canScrollBackward) { - ScrollToBottomFab(coroutineScope = coroutineScope, listState = listState, unreadCount = unreadCount) - } - } - } -} - -@Suppress("LongParameterList") -@Composable -private fun DesktopMessageItemRow( - message: org.meshtastic.core.model.Message, - node: Node, - ourNode: Node, - selected: Boolean, - inSelectionMode: Boolean, - selectedMessageIds: androidx.compose.runtime.MutableState>, - contactKey: String, - viewModel: MessageViewModel, - listState: androidx.compose.foundation.lazy.LazyListState, - messages: List, - onShowStatusDialog: (org.meshtastic.core.model.Message) -> Unit, - onReply: (org.meshtastic.core.model.Message?) -> Unit, - hasSamePrev: Boolean, - hasSameNext: Boolean, - showUserName: Boolean, - quickEmojis: List, -) { - val coroutineScope = rememberCoroutineScope() - - MessageItem( - message = message, - node = node, - ourNode = ourNode, - selected = selected, - inSelectionMode = inSelectionMode, - onClick = { if (inSelectionMode) selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) }, - onLongClick = { - if (inSelectionMode) { - selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) - } - }, - onSelect = { selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) }, - onDelete = { viewModel.deleteMessages(listOf(message.uuid)) }, - onReply = { onReply(message) }, - sendReaction = { emoji -> - val hasReacted = - message.emojis.any { reaction -> - (reaction.user.id == ourNode.user.id || reaction.user.id == DataPacket.ID_LOCAL) && - reaction.emoji == emoji - } - if (!hasReacted) { - viewModel.sendReaction(emoji, message.packetId, contactKey) - } - }, - onStatusClick = { onShowStatusDialog(message) }, - onNavigateToOriginalMessage = { replyId -> - coroutineScope.launch { - val targetIndex = messages.indexOfFirst { it.packetId == replyId }.takeIf { it != -1 } - if (targetIndex != null) { - listState.animateScrollToItem(targetIndex) - } - } - }, - emojis = message.emojis, - showUserName = showUserName, - hasSamePrev = hasSamePrev, - hasSameNext = hasSameNext, - quickEmojis = quickEmojis, - ) -} - -private fun Set.toggle(uuid: Long): Set = if (contains(uuid)) this - uuid else this + uuid - -/** Debounce delay before marking messages as read after scroll settles. */ -private const val SCROLL_SETTLE_MILLIS = 300L diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt deleted file mode 100644 index 249b2fcdf..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.ui.nodes - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -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.rememberListDetailPaneScaffoldNavigator -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.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.navigation.Route -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.node_count_template -import org.meshtastic.core.resources.nodes -import org.meshtastic.core.ui.component.EmptyDetailPlaceholder -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.MeshtasticImportFAB -import org.meshtastic.core.ui.component.SharedContactDialog -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Nodes -import org.meshtastic.core.ui.viewmodel.UIViewModel -import org.meshtastic.feature.node.component.NodeContextMenu -import org.meshtastic.feature.node.component.NodeFilterTextField -import org.meshtastic.feature.node.component.NodeItem -import org.meshtastic.feature.node.detail.NodeDetailContent -import org.meshtastic.feature.node.detail.NodeDetailViewModel -import org.meshtastic.feature.node.detail.NodeRequestEffect -import org.meshtastic.feature.node.list.NodeListViewModel -import org.meshtastic.feature.node.model.NodeDetailAction - -/** - * Desktop adaptive node list screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive. - * - * On wide screens, the node list is shown on the left and the selected node detail on the right. On narrow screens, the - * scaffold automatically switches to a single-pane layout. - * - * Uses the shared [NodeListViewModel] and commonMain composables ([NodeItem], [NodeFilterTextField], [MainAppBar]). The - * detail pane renders the shared [NodeDetailContent] from commonMain with the full node detail sections (identity, - * device actions, position, hardware details, notes, administration). Android-only overlays (compass permissions, - * bottom sheets) are no-ops on desktop. - */ -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Suppress("LongMethod", "CyclomaticComplexMethod") -@Composable -fun DesktopAdaptiveNodeListScreen( - viewModel: NodeListViewModel, - initialNodeId: Int? = null, - onNavigate: (Route) -> Unit = {}, - uiViewModel: UIViewModel = koinViewModel(), -) { - val state by viewModel.nodesUiState.collectAsStateWithLifecycle() - val nodes by viewModel.nodeList.collectAsStateWithLifecycle() - val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0) - val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0) - val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle() - val ignoredNodeCount = unfilteredNodes.count { it.isIgnored } - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val navigator = rememberListDetailPaneScaffoldNavigator() - val scope = rememberCoroutineScope() - val listState = rememberLazyListState() - - val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() - var shareNode by remember { mutableStateOf(null) } - - if (shareNode != null) { - SharedContactDialog(contact = shareNode, onDismiss = { shareNode = null }) - } - - LaunchedEffect(initialNodeId) { - initialNodeId?.let { nodeId -> navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) } - } - - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - listPane = { - AnimatedPane { - Scaffold( - topBar = { - MainAppBar( - title = stringResource(Res.string.nodes), - subtitle = - stringResource( - Res.string.node_count_template, - onlineNodeCount, - nodes.size, - totalNodeCount, - ), - ourNode = ourNode, - showNodeChip = false, - canNavigateUp = false, - onNavigateUp = {}, - actions = {}, - onClickChip = {}, - ) - }, - floatingActionButton = { - if (connectionState == ConnectionState.Connected) { - MeshtasticImportFAB( - onImport = { uriString -> - uiViewModel.handleScannedUri( - org.meshtastic.core.common.util.MeshtasticUri(uriString), - ) { - // OnInvalid - } - }, - sharedContact = sharedContactRequested, - onDismissSharedContact = { uiViewModel.clearSharedContactRequested() }, - isContactContext = true, - ) - } - }, - ) { contentPadding -> - Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) { - LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { - item { - NodeFilterTextField( - modifier = - Modifier.fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceDim) - .padding(8.dp), - filterText = state.filter.filterText, - onTextChange = { viewModel.nodeFilterText = it }, - currentSortOption = state.sort, - onSortSelect = viewModel::setSortOption, - includeUnknown = state.filter.includeUnknown, - onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() }, - excludeInfrastructure = state.filter.excludeInfrastructure, - onToggleExcludeInfrastructure = { - viewModel.nodeFilterPreferences.toggleExcludeInfrastructure() - }, - onlyOnline = state.filter.onlyOnline, - onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() }, - onlyDirect = state.filter.onlyDirect, - onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() }, - showIgnored = state.filter.showIgnored, - onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() }, - ignoredNodeCount = ignoredNodeCount, - excludeMqtt = state.filter.excludeMqtt, - onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() }, - ) - } - - items(nodes, key = { it.num }) { node -> - var expanded by remember { mutableStateOf(false) } - val isActive = navigator.currentDestination?.contentKey == node.num - - Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { - val longClick = - if (node.num != ourNode?.num) { - { expanded = true } - } else { - null - } - - NodeItem( - thisNode = ourNode, - thatNode = node, - distanceUnits = state.distanceUnits, - tempInFahrenheit = state.tempInFahrenheit, - onClick = { - scope.launch { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, node.num) - } - }, - onLongClick = longClick, - connectionState = connectionState, - isActive = isActive, - ) - - val isThisNode = remember(node) { ourNode?.num == node.num } - if (!isThisNode) { - NodeContextMenu( - expanded = expanded, - node = node, - onFavorite = { viewModel.favoriteNode(node) }, - onIgnore = { viewModel.ignoreNode(node) }, - onMute = { viewModel.muteNode(node) }, - onRemove = { viewModel.removeNode(node) }, - onDismiss = { expanded = false }, - ) - } - } - } - item { Spacer(modifier = Modifier.height(16.dp)) } - } - } - } - } - }, - detailPane = { - AnimatedPane { - navigator.currentDestination?.contentKey?.let { nodeNum -> - val detailViewModel: NodeDetailViewModel = koinViewModel(key = "node-detail-$nodeNum") - LaunchedEffect(nodeNum) { detailViewModel.start(nodeNum) } - val detailUiState by detailViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(Unit) { - detailViewModel.effects.collect { effect -> - if (effect is NodeRequestEffect.ShowFeedback) { - snackbarHostState.showSnackbar(effect.text.resolve()) - } - } - } - - Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues -> - NodeDetailContent( - modifier = Modifier.padding(paddingValues), - uiState = detailUiState, - onAction = { action -> - when (action) { - is NodeDetailAction.Navigate -> onNavigate(action.route) - is NodeDetailAction.TriggerServiceAction -> - detailViewModel.onServiceAction(action.action) - is NodeDetailAction.ShareContact -> shareNode = detailUiState.node - is NodeDetailAction.HandleNodeMenuAction -> { - val menuAction = action.action - if ( - menuAction - is org.meshtastic.feature.node.component.NodeMenuAction.DirectMessage - ) { - val routeStr = - detailViewModel.getDirectMessageRoute( - menuAction.node, - detailUiState.ourNode, - ) - onNavigate( - org.meshtastic.core.navigation.ContactsRoutes.Messages( - contactKey = routeStr, - ), - ) - } else { - detailViewModel.handleNodeMenuAction(menuAction) - } - } - else -> {} // Actions requiring Android APIs are no-ops on desktop - } - }, - onFirmwareSelect = { /* Firmware update not available on desktop */ }, - onSaveNotes = { num, notes -> detailViewModel.setNodeNotes(num, notes) }, - ) - } - } ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes)) - } - }, - ) -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt deleted file mode 100644 index 53c21d950..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.desktop.ui.settings - -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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 org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.advanced -import org.meshtastic.core.resources.config_network_eth_enabled_summary -import org.meshtastic.core.resources.config_network_udp_enabled_summary -import org.meshtastic.core.resources.config_network_wifi_enabled_summary -import org.meshtastic.core.resources.connection_status -import org.meshtastic.core.resources.ethernet_config -import org.meshtastic.core.resources.ethernet_enabled -import org.meshtastic.core.resources.ethernet_ip -import org.meshtastic.core.resources.gateway -import org.meshtastic.core.resources.ip -import org.meshtastic.core.resources.ipv4_mode -import org.meshtastic.core.resources.network -import org.meshtastic.core.resources.ntp_server -import org.meshtastic.core.resources.password -import org.meshtastic.core.resources.rsyslog_server -import org.meshtastic.core.resources.ssid -import org.meshtastic.core.resources.subnet -import org.meshtastic.core.resources.udp_enabled -import org.meshtastic.core.resources.wifi_config -import org.meshtastic.core.resources.wifi_enabled -import org.meshtastic.core.resources.wifi_ip -import org.meshtastic.core.ui.component.DropDownPreference -import org.meshtastic.core.ui.component.EditIPv4Preference -import org.meshtastic.core.ui.component.EditPasswordPreference -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.component.ListItem -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.proto.Config - -@Composable -@Suppress("LongMethod", "CyclomaticComplexMethod") -fun DesktopNetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { - val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val networkConfig = state.radioConfig.network ?: Config.NetworkConfig() - val formState = rememberConfigState(initialValue = networkConfig) - - val focusManager = LocalFocusManager.current - - RadioConfigScreenList( - title = stringResource(Res.string.network), - onBack = onBack, - configState = formState, - enabled = state.connected, - responseState = state.responseState, - onDismissPacketResponse = viewModel::clearPacketResponse, - onSave = { - val config = Config(network = it) - viewModel.setConfig(config) - }, - ) { - // Display device connection status - state.deviceConnectionStatus?.let { connectionStatus -> - val ws = connectionStatus.wifi?.status - val es = connectionStatus.ethernet?.status - if (ws?.is_connected == true || es?.is_connected == true) { - item { - TitledCard(title = stringResource(Res.string.connection_status)) { - ws?.let { wifiStatus -> - if (wifiStatus.is_connected) { - ListItem( - text = stringResource(Res.string.wifi_ip), - supportingText = formatIpAddress(wifiStatus.ip_address ?: 0), - trailingIcon = null, - ) - } - } - es?.let { ethernetStatus -> - if (ethernetStatus.is_connected) { - ListItem( - text = stringResource(Res.string.ethernet_ip), - supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0), - trailingIcon = null, - ) - } - } - } - } - } - } - if (state.metadata?.hasWifi == true) { - item { - TitledCard(title = stringResource(Res.string.wifi_config)) { - SwitchPreference( - title = stringResource(Res.string.wifi_enabled), - summary = stringResource(Res.string.config_network_wifi_enabled_summary), - checked = formState.value.wifi_enabled ?: false, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.ssid), - value = formState.value.wifi_ssid ?: "", - maxSize = 32, // wifi_ssid max_size:33 - 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(wifi_ssid = it) }, - ) - HorizontalDivider() - EditPasswordPreference( - title = stringResource(Res.string.password), - value = formState.value.wifi_psk ?: "", - maxSize = 64, // wifi_psk max_size:65 - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) }, - ) - } - } - } - if (state.metadata?.hasEthernet == true) { - item { - TitledCard(title = stringResource(Res.string.ethernet_config)) { - SwitchPreference( - title = stringResource(Res.string.ethernet_enabled), - summary = stringResource(Res.string.config_network_eth_enabled_summary), - checked = formState.value.eth_enabled ?: false, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - } - - if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) { - item { - TitledCard(title = stringResource(Res.string.network)) { - SwitchPreference( - title = stringResource(Res.string.udp_enabled), - summary = stringResource(Res.string.config_network_udp_enabled_summary), - checked = (formState.value.enabled_protocols ?: 0) == 1, - enabled = state.connected, - onCheckedChange = { - formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0) - }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - } - - item { - TitledCard(title = stringResource(Res.string.advanced)) { - EditTextPreference( - title = stringResource(Res.string.ntp_server), - value = formState.value.ntp_server ?: "", - maxSize = 32, // ntp_server max_size:33 - enabled = state.connected, - isError = formState.value.ntp_server?.isEmpty() ?: true, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ntp_server = it) }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.rsyslog_server), - value = formState.value.rsyslog_server ?: "", - maxSize = 32, // rsyslog_server max_size:33 - enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) }, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.ipv4_mode), - enabled = state.connected, - items = Config.NetworkConfig.AddressMode.entries.map { it to it.name }, - selectedItem = formState.value.address_mode ?: Config.NetworkConfig.AddressMode.DHCP, - onItemSelected = { formState.value = formState.value.copy(address_mode = it) }, - ) - HorizontalDivider() - val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config() - EditIPv4Preference( - title = stringResource(Res.string.ip), - value = ipv4.ip, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) }, - ) - HorizontalDivider() - EditIPv4Preference( - title = stringResource(Res.string.gateway), - value = ipv4.gateway, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) }, - ) - HorizontalDivider() - EditIPv4Preference( - title = stringResource(Res.string.subnet), - value = ipv4.subnet, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) }, - ) - HorizontalDivider() - EditIPv4Preference( - title = "DNS", - value = ipv4.dns, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) }, - ) - } - } - } -} - -@Suppress("detekt:MagicNumber") -private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." + - "${(ipAddress shr 8) and 0xFF}." + - "${(ipAddress shr 16) and 0xFF}." + - "${(ipAddress shr 24) and 0xFF}" diff --git a/docs/agent-playbooks/README.md b/docs/agent-playbooks/README.md index 375920e15..15550deea 100644 --- a/docs/agent-playbooks/README.md +++ b/docs/agent-playbooks/README.md @@ -27,6 +27,7 @@ Version catalog aliases split cleanly by fork provenance. **Use the right prefix |---|---|---| | `jetbrains-lifecycle-*` | `org.jetbrains.androidx.lifecycle:*` | `commonMain`, `androidMain` | | `jetbrains-navigation3-*` | `org.jetbrains.androidx.navigation3:*` | `commonMain`, `androidMain` | +| `jetbrains-navigationevent-*` | `org.jetbrains.androidx.navigationevent:*` | `commonMain`, `androidMain` | | `jetbrains-compose-material3-adaptive-*` | `org.jetbrains.compose.material3.adaptive:*` | `commonMain`, `androidMain` | | `androidx-lifecycle-process` | `androidx.lifecycle:lifecycle-process` | `androidMain` only — `ProcessLifecycleOwner` | | `androidx-lifecycle-runtime-ktx` | `androidx.lifecycle:lifecycle-runtime-ktx` | `androidMain` only | diff --git a/docs/agent-playbooks/common-practices.md b/docs/agent-playbooks/common-practices.md index 212af9517..05190aead 100644 --- a/docs/agent-playbooks/common-practices.md +++ b/docs/agent-playbooks/common-practices.md @@ -27,7 +27,8 @@ This document captures discoverable patterns that are already used in the reposi - Keep shared dialogs/components in `core:ui` where possible. - Put localizable UI strings in Compose Multiplatform resources: `core/resources/src/commonMain/composeResources/values/strings.xml`. - Use `stringResource(Res.string.key)` from shared resources in feature screens. -- Example usage: `feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`. +- When retrieving strings in non-composable Coroutines, Managers, or ViewModels, use `getStringSuspend()`. Never use the blocking `getString()` inside a coroutine as it will crash iOS and freeze the UI thread. +- Example usage: `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`. ## 5) Platform abstraction in shared UI diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md index 3d42ffbe2..e00166729 100644 --- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md +++ b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md @@ -33,17 +33,18 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver - Do keep route definitions in `core:navigation` and use typed route objects. - Don't mutate back navigation with custom stacks disconnected from app backstack. - Do mutate `NavBackStack` with `add(...)` and `removeLastOrNull()`. +- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI. +- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures. ### Current code anchors (Navigation 3) - Typed routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` +- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` - App root backstack + `NavDisplay`: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` -- Graph entry provider pattern: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` -- Feature-level Navigation 3 usage: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt` +- Shared graph entry provider pattern: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` - Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` -- Desktop real feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt` -- Desktop `SavedStateConfiguration` for polymorphic NavKey serialization: `DesktopMainScreen.kt` +- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` + ## Quick pre-PR checks for DI/navigation edits diff --git a/docs/agent-playbooks/task-playbooks.md b/docs/agent-playbooks/task-playbooks.md index be25a9c7c..1929f157c 100644 --- a/docs/agent-playbooks/task-playbooks.md +++ b/docs/agent-playbooks/task-playbooks.md @@ -43,16 +43,19 @@ Reference examples: 1. Define/extend route keys in `core:navigation`. 2. Implement feature entry/content using Navigation 3 types (`NavKey`, `NavBackStack`, `EntryProviderScope`). -3. Add graph entries under the relevant feature module's `navigation` package (e.g., `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation`). -4. Use backstack mutation (`add`, `removeLastOrNull`) instead of introducing controller-coupled APIs. -5. Verify deep-link behavior if route is externally reachable. +3. Add graph entries under the relevant feature module's `navigation` package (e.g., `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation`). +4. If the entry content depends on platform-specific UI (e.g. Activity context or specific desktop wrappers), use `expect`/`actual` declarations for the content composables. +5. Use backstack mutation (`add`, `removeLastOrNull`) instead of introducing controller-coupled APIs. +6. Verify deep-link behavior if route is externally reachable. Reference examples: -- App graph wiring: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Shared graph wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Android specific content: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt` +- Desktop specific content: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt` - Feature intro graph pattern: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt` - Desktop nav shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` -- Desktop feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt` +- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` + ## Playbook E: Add flavor/platform-specific UI implementation @@ -82,8 +85,8 @@ Reference examples: - Desktop DI: `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` - Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` - Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` -- Desktop real feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt` -- Desktop-specific screen: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt` +- Desktop shared feature wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Desktop-specific screen: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt` - Roadmap: `docs/roadmap.md` diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index a9c064667..3bdef3723 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -185,7 +185,7 @@ Ordered by impact × effort: | Priority | Extraction | Impact | Effort | Enables | |---:|---|---|---|---| -| 1 | `java.*` purge from `commonMain` (B1, B2) | High | Low | iOS target declaration | +| 1 | ~~`java.*` purge from `commonMain` (B1, B2)~~ | High | Low | ~~iOS target declaration~~ ✅ Done | | 2 | Radio transport interfaces to `core:repository` (A2) | High | Medium | Transport unification | | 3 | `core:testing` shared fixtures (D2) | Medium | Low | Feature commonTest | | 4 | Feature `commonTest` (D1) | Medium | Medium | KMP test coverage | @@ -194,7 +194,7 @@ Ordered by impact × effort: | 7 | ~~Desktop Koin auto-wiring (C1, C2)~~ | Medium | Low | ✅ Resolved 2026-03-13 | | 8 | MQTT KMP (B3) | Medium | High | Desktop/iOS MQTT | | 9 | KMP charts (B4) | Medium | High | Desktop metrics | -| 10 | iOS target declaration | High | Low | CI purity gate | +| 10 | ~~iOS target declaration~~ | High | Low | ~~CI purity gate~~ ✅ Done | --- @@ -205,7 +205,7 @@ Ordered by impact × effort: | Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | | Shared feature/UI logic | 9.5/10 | **8.5/10** | All 7 KMP features; connections unified; Vico charts in commonMain | | Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files | -| Multi-target readiness | 8/10 | **8/10** | Full JVM; release-ready desktop; iOS not declared | +| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers | | DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | | Test maturity | — | **8/10** | 131 commonTest + 89 platform-specific = 219 tests across all 7 features; core:testing established | diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md index f8ae3a0d8..a314af54d 100644 --- a/docs/decisions/navigation3-parity-2026-03.md +++ b/docs/decisions/navigation3-parity-2026-03.md @@ -10,7 +10,7 @@ # Navigation 3 Parity Strategy (Android + Desktop) **Date:** 2026-03-11 -**Status:** Active +**Status:** Implemented (2026-03-21) **Scope:** `app` and `desktop` navigation structure using shared `core:navigation` routes ## Context @@ -27,13 +27,14 @@ Both modules still define separate graph-builder files (`app/navigation/*.kt`, ` - Both shells iterate `TopLevelDestination.entries` from `core:navigation/commonMain`. - Shared icon mapping lives in `core:ui` (`TopLevelDestinationExt.icon`). - Parity tests exist in both `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -2. **Feature coverage differs by intent and by implementation.** - - Desktop intentionally uses placeholders for map and several node/message detail flows. - - Android wires real implementations for map, message/share flows, and more node detail paths. -3. **Saved-state route registration is desktop-only and manual.** - - `DesktopMainScreen.kt` maintains a large `SavedStateConfiguration` serializer list that must stay in sync with `Routes.kt` and desktop graph entries. -4. **Route keys are shared; graph registration is per-platform.** - - This is the expected state — platform shells wire entries differently while consuming the same route types. +2. **Feature coverage is unified via `commonMain` feature graphs.** + - The `settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`, `firmwareGraph`, and `mapGraph` are now fully shared and exported from their respective feature modules' `commonMain` source sets. + - Desktop acts as a thin shell, delegating directly to these shared graphs. +3. **Saved-state route registration is fully shared.** + - `MeshtasticNavSavedStateConfig` in `core:navigation/commonMain` maintains the unified `SavedStateConfiguration` serializer list. + - Both Android and Desktop reference this shared config when instantiating `rememberNavBackStack`. +4. **Predictive back handling is KMP native.** + - Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`. ## Alpha04 Changelog Impact Check (2026-03-13) @@ -147,9 +148,11 @@ Adopt a **hybrid parity model**: ## Source Anchors - Shared routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` +- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` - Android shell: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` -- Android graph registrations: `feature/*/src/androidMain/kotlin/org/meshtastic/feature/*/navigation/` +- Shared graph registrations: `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/` +- Platform graph content: `feature/*/src/{androidMain,jvmMain}/kotlin/org/meshtastic/feature/*/navigation/` - Desktop shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop graph registrations: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/` +- Desktop graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` diff --git a/docs/kmp-status.md b/docs/kmp-status.md index ffa4f0c66..aaec88b0f 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -43,9 +43,9 @@ Modules that share JVM-specific code between Android and desktop now standardize | Module | UI in commonMain? | Desktop wired? | |---|:---:|:---:| -| `feature:settings` | ✅ | ✅ ~35 real screens; shared `ChannelScreen` & `ViewModel` | -| `feature:node` | ✅ | ✅ Adaptive list-detail; shared `NodeContextMenu` | -| `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; 17 shared files in commonMain (ViewModels, MessageBubble, MessageItem, QuickChat, Reactions, DeliveryInfo, actions, events) | +| `feature:settings` | ✅ | ✅ ~35 real screens; fully shared `settingsGraph` and UI | +| `feature:node` | ✅ | ✅ Adaptive list-detail; fully shared `nodesGraph`, `PositionLogScreen`, and `NodeContextMenu` | +| `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` | | `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection | | `feature:intro` | ✅ | — | | `feature:map` | ✅ | Placeholder; shared `NodeMapViewModel` | @@ -63,7 +63,7 @@ Working Compose Desktop application with: - **Desktop language picker** backed by `UiPreferencesDataSource.locale`, with immediate Compose Multiplatform resource updates - **Navigation-preserving locale switching** via `Main.kt` `staticCompositionLocalOf` recomposition instead of recreating the Nav3 backstack - Node detail metrics screens (Device, Environment, Signal, Power, Pax) wired with shared KMP + Vico charts -- 6 desktop-specific screens (Settings, Device, Position, Network, Security, ExternalNotification) +- **Feature-driven Architecture:** Desktop navigation completely relies on feature modules via `commonMain` exported graphs (`settingsGraph`, `nodesGraph`, `contactsGraph`, etc.), reducing the desktop module to a simple host shell. - **Native notifications and system tray icon** wired via `DesktopNotificationManager` - **Native release pipeline** generating `.dmg` (macOS), `.msi` (Windows), and `.deb` (Linux) installers in CI @@ -74,7 +74,7 @@ Working Compose Desktop application with: | Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified | | Shared feature/UI logic | **8.5/10** | All 7 KMP; feature:connections unified with dynamic transport detection | | 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 | **8/10** | Full JVM; release-ready desktop; iOS not declared | +| 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 | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | | Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features | @@ -88,8 +88,8 @@ Working Compose Desktop application with: | Android-first structural KMP | ~100% | | Shared business logic | ~98% | | Shared feature/UI | ~95% | -| True multi-target readiness | ~75% | -| "Add iOS without surprises" | ~65% | +| True multi-target readiness | ~85% | +| "Add iOS without surprises" | ~100% | ## Proposed Next Steps for KMP Migration @@ -97,7 +97,8 @@ Based on the latest codebase investigation, the following steps are proposed to 1. **Wire Desktop Features:** Complete desktop UI wiring for `feature:intro` and implement a shared fallback for `feature:map` (which is currently a placeholder on desktop). 2. **Decouple Firmware DFU:** `feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it into a separate plugin to allow the core `feature:firmware` module to be fully utilized on desktop/iOS. -3. **Prepare for iOS Target:** Set up an initial skeleton Xcode project to start validating `commonMain` compilation on Kotlin/Native (iOS). +3. **Flesh out iOS Actuals:** Complete the actual implementations for iOS UI stubs (e.g., `AboutLibrariesLoader`, `rememberOpenMap`, `SettingsMainScreen`) that were recently added to unblock iOS compilation. +4. **Boot iOS Target:** Set up an initial skeleton Xcode project to start running the now-compiling `iosSimulatorArm64` / `iosArm64` binaries on a real simulator/device. ## Key Architecture Decisions diff --git a/docs/roadmap.md b/docs/roadmap.md index 058b0671d..b97be24c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,8 @@ These items address structural gaps identified in the March 2026 architecture re | Add feature module `commonTest` (settings, node, messaging) | Medium | Medium | ✅ | | Desktop Koin `checkModules()` integration test | Medium | Low | ✅ | | Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ | -here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ | +| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ | +| **iOS CI gate (compile-only validation)** | High | Medium | ✅ | ## Active Work @@ -63,7 +64,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | | Feature | Status | |---|---| -| Settings | ✅ ~35 real screens (6 desktop-specific) + desktop locale picker with in-place recomposition | +| Settings | ✅ ~35 real screens (fully shared) + desktop locale picker with in-place recomposition | | Node list | ✅ Adaptive list-detail with real `NodeDetailContent` | | Messaging | ✅ Adaptive contacts with real message view + send | | Connections | ✅ Unified shared UI with dynamic transport detection | @@ -85,11 +86,11 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | - Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane. - Leverage the existing `BaseMapViewModel` contract. 3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. -4. **iOS CI gate** — add `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI (compile-only, no implementations) to ensure `commonMain` remains pure. +4. **iOS CI gate** — ✅ **Done:** added `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI. `commonMain` successfully compiles on iOS. ## Medium-Term Priorities (60 days) -1. **iOS proof target** — Begin stubbing iOS target implementations (`NoopStubs.kt` equivalent) and setup an Xcode skeleton project. +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. diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts index 3bc65aec8..66c51bab7 100644 --- a/feature/connections/build.gradle.kts +++ b/feature/connections/build.gradle.kts @@ -18,8 +18,6 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.feature.connections" diff --git a/feature/connections/detekt-baseline.xml b/feature/connections/detekt-baseline.xml index 9ba3ffcf6..c373eea43 100644 --- a/feature/connections/detekt-baseline.xml +++ b/feature/connections/detekt-baseline.xml @@ -1,13 +1,5 @@ - - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790 - MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114 - MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200 - MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200 - SwallowedException:NsdManager.kt$ex: IllegalArgumentException - + diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 3f2c9014f..426e8476d 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.RadioController @@ -84,7 +85,7 @@ open class ScannerViewModel( timeout = kotlin.time.Duration.INFINITE, serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, ) - .flowOn(kotlinx.coroutines.Dispatchers.IO) + .flowOn(ioDispatcher) .collect { device -> if (!scannedBleDevices.value.containsKey(device.address)) { scannedBleDevices.update { current -> current + (device.address to device) } diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt similarity index 83% rename from feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt rename to feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index c94c1ab3a..d239dcf00 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -22,7 +22,7 @@ import androidx.navigation3.runtime.NavKey import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ConnectionsRoutes import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.feature.connections.AndroidScannerViewModel +import org.meshtastic.feature.connections.ScannerViewModel import org.meshtastic.feature.connections.ui.ConnectionsScreen import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -30,12 +30,9 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) { entry { ConnectionsScreen( - scanModel = koinViewModel(), + scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { - // Navigation 3 ignores back stack behavior options; we handle this by popping if necessary. - backStack.add(NodesRoutes.NodeDetailGraph(it)) - }, + onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, onConfigNavigate = { route -> backStack.add(route) }, ) @@ -43,7 +40,7 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) entry { ConnectionsScreen( - scanModel = koinViewModel(), + scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt new file mode 100644 index 000000000..f9f26deb3 --- /dev/null +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.navigation + +import androidx.compose.runtime.Composable +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.feature.firmware.FirmwareUpdateScreen +import org.meshtastic.feature.firmware.FirmwareUpdateViewModel + +@Composable +actual fun FirmwareScreen(onNavigateUp: () -> Unit) { + val viewModel = koinViewModel() + FirmwareUpdateScreen(onNavigateUp = onNavigateUp, viewModel = viewModel) +} diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt similarity index 72% rename from feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt rename to feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt index 87ee9f82c..c71d597bd 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt @@ -16,17 +16,15 @@ */ package org.meshtastic.feature.firmware.navigation +import androidx.compose.runtime.Composable import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.FirmwareRoutes -import org.meshtastic.feature.firmware.FirmwareUpdateScreen -import org.meshtastic.feature.firmware.FirmwareUpdateViewModel fun EntryProviderScope.firmwareGraph(backStack: NavBackStack) { - entry { - val viewModel = koinViewModel() - FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel) - } + entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } + entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } } + +@Composable expect fun FirmwareScreen(onNavigateUp: () -> Unit) diff --git a/feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt new file mode 100644 index 000000000..750a409c3 --- /dev/null +++ b/feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.navigation + +import androidx.compose.runtime.Composable + +@Composable +actual fun FirmwareScreen(onNavigateUp: () -> Unit) { + // TODO: Implement iOS firmware screen +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/firmware/DesktopFirmwareScreen.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareScreen.kt similarity index 99% rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/firmware/DesktopFirmwareScreen.kt rename to feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareScreen.kt index f31dd1e05..d9e2de815 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/firmware/DesktopFirmwareScreen.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.desktop.ui.firmware +package org.meshtastic.feature.firmware import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt new file mode 100644 index 000000000..5e6d85da7 --- /dev/null +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.navigation + +import androidx.compose.runtime.Composable +import org.meshtastic.feature.firmware.DesktopFirmwareScreen + +@Composable +actual fun FirmwareScreen(onNavigateUp: () -> Unit) { + DesktopFirmwareScreen() +} diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index 81997c438..6a3f9f5a0 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -21,8 +21,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.feature.intro" diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index ff1010c15..11cd70588 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -20,8 +20,6 @@ plugins { } kotlin { - jvm() - @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.feature.map" diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt new file mode 100644 index 000000000..d2e6e9895 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.navigation + +import androidx.compose.runtime.Composable +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.feature.map.MapScreen +import org.meshtastic.feature.map.SharedMapViewModel + +@Composable +actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { + val viewModel = koinViewModel() + MapScreen( + viewModel = viewModel, + onClickNodeChip = onClickNodeChip, + navigateToNodeDetails = navigateToNodeDetails, + waypointId = waypointId, + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 73dcbe499..bd8f8615b 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node @@ -139,7 +139,7 @@ open class BaseMapViewModel( fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) - fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) } + fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) @@ -151,7 +151,7 @@ open class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - viewModelScope.launch(Dispatchers.IO) { radioController.sendMessage(p) } + viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) } } fun generatePacketId(): Int = radioController.getPacketId() diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt similarity index 83% rename from feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index c590c1a7a..ba9420bf0 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -16,23 +16,22 @@ */ package org.meshtastic.feature.map.navigation +import androidx.compose.runtime.Composable import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.MapRoutes import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.feature.map.MapScreen -import org.meshtastic.feature.map.SharedMapViewModel fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> - val viewModel = koinViewModel() - MapScreen( - viewModel = viewModel, + MapMainScreen( onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, waypointId = args.waypointId, ) } } + +@Composable +expect fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt new file mode 100644 index 000000000..b08a13126 --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.navigation + +import androidx.compose.runtime.Composable + +@Composable +actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { + // TODO: Implement iOS map main screen +} diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt new file mode 100644 index 000000000..3e0810824 --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable + +@Composable +actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { + // Desktop placeholder for now + org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map") +} + +@Composable +internal fun PlaceholderScreen(name: String) { + androidx.compose.foundation.layout.Box( + modifier = androidx.compose.ui.Modifier.fillMaxSize(), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + androidx.compose.material3.Text( + text = name, + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index 66dbd0e41..7b245ac08 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -43,7 +43,9 @@ kotlin { implementation(projects.core.ui) implementation(libs.jetbrains.navigation3.runtime) + implementation(libs.jetbrains.navigationevent.compose) implementation(libs.androidx.paging.common) + implementation(libs.androidx.paging.compose) // JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold) implementation(libs.jetbrains.compose.material3.adaptive) @@ -51,10 +53,7 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.navigation) } - androidMain.dependencies { - implementation(libs.androidx.paging.compose) - implementation(libs.androidx.work.runtime.ktx) - } + androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } androidUnitTest.dependencies { implementation(libs.androidx.work.testing) diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt new file mode 100644 index 000000000..79cfd92b4 --- /dev/null +++ b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.messaging.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.Flow +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.ui.component.ScrollToTopEvent +import org.meshtastic.core.ui.viewmodel.UIViewModel +import org.meshtastic.feature.messaging.MessageViewModel +import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen +import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel + +@Composable +actual fun ContactsEntryContent( + backStack: NavBackStack, + scrollToTopEvents: Flow, + initialContactKey: String?, + initialMessage: String, +) { + val uiViewModel: UIViewModel = koinViewModel() + val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() + val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() + val contactsViewModel = koinViewModel() + val messageViewModel = koinViewModel() + initialContactKey?.let { messageViewModel.setContactKey(it) } + + AdaptiveContactsScreen( + backStack = backStack, + contactsViewModel = contactsViewModel, + messageViewModel = messageViewModel, + scrollToTopEvents = scrollToTopEvents, + sharedContactRequested = sharedContactRequested, + requestChannelSet = requestChannelSet, + onHandleScannedUri = uiViewModel::handleScannedUri, + onClearSharedContactRequested = uiViewModel::clearSharedContactRequested, + onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl, + initialContactKey = initialContactKey, + initialMessage = initialMessage, + ) +} diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 6abacade7..30f65afff 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging.component import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test @@ -26,7 +27,6 @@ import org.junit.runner.RunWith import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider @RunWith(AndroidJUnit4::class) class MessageItemTest { diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt similarity index 98% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/Message.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index b5116d3fb..7be0b4027 100644 --- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.messaging -import android.content.ClipData import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -78,6 +77,7 @@ import org.meshtastic.core.resources.unknown_channel import org.meshtastic.core.ui.component.SharedContactDialog import org.meshtastic.core.ui.component.smartScrollToIndex import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.util.createClipEntry import org.meshtastic.feature.messaging.component.ActionModeTopBar import org.meshtastic.feature.messaging.component.DeleteMessageDialog import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES @@ -86,7 +86,6 @@ import org.meshtastic.feature.messaging.component.MessageTopBar import org.meshtastic.feature.messaging.component.QuickChatRow import org.meshtastic.feature.messaging.component.ReplySnippet import org.meshtastic.feature.messaging.component.ScrollToBottomFab -import java.nio.charset.StandardCharsets private const val ROUNDED_CORNER_PERCENT = 100 private const val MAX_LINES = 3 @@ -243,11 +242,7 @@ fun MessageScreen( is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum) MessageScreenEvent.NavigateBack -> onNavigateBack() is MessageScreenEvent.CopyToClipboard -> { - coroutineScope.launch { - clipboardManager.setClipEntry( - androidx.compose.ui.platform.ClipEntry(ClipData.newPlainText(event.text, event.text)), - ) - } + coroutineScope.launch { clipboardManager.setClipEntry(createClipEntry(event.text, event.text)) } selectedMessageIds.value = emptySet() } } @@ -450,7 +445,7 @@ private fun MessageInput( val currentByteLength = remember(currentText) { // Recalculate only when text changes - currentText.toByteArray(StandardCharsets.UTF_8).size + currentText.encodeToByteArray().size } val isOverLimit = currentByteLength > maxByteSize diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index d93006619..1c84c26f1 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message @@ -190,7 +190,7 @@ class MessageViewModel( } fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } } fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) @@ -218,10 +218,10 @@ class MessageViewModel( viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } fun deleteMessages(uuidList: List) = - viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) } + viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) } fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE if (lastReadTimestamp <= existingTimestamp) { return@launch diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt index 685732197..02278d15b 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt @@ -202,7 +202,7 @@ internal fun EditQuickChatDialog( label = stringResource(Res.string.message), value = actionInput.message, maxSize = 200, - getSize = { it.toByteArray().size + 1 }, + getSize = { it.encodeToByteArray().size + 1 }, modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), ) { actionInput = actionInput.copy(message = it) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt index e114d3964..53d023d08 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt @@ -18,9 +18,9 @@ package org.meshtastic.feature.messaging import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.repository.QuickChatActionRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @@ -31,7 +31,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) fun updateActionPositions(actions: List) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { for (position in actions.indices) { quickChatActionRepository.setItemPosition(actions[position].uuid, position) } @@ -39,8 +39,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR } fun addQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) } + viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) } fun deleteQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) } + viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) } } diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt similarity index 67% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 79017141b..15e2de883 100644 --- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -17,27 +17,23 @@ package org.meshtastic.feature.messaging.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.ui.component.ScrollToTopEvent -import org.meshtastic.core.ui.viewmodel.UIViewModel -import org.meshtastic.feature.messaging.MessageViewModel import org.meshtastic.feature.messaging.QuickChatScreen import org.meshtastic.feature.messaging.QuickChatViewModel -import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel import org.meshtastic.feature.messaging.ui.sharing.ShareScreen @Suppress("LongMethod") fun EntryProviderScope.contactsGraph( backStack: NavBackStack, - scrollToTopEvents: Flow, + scrollToTopEvents: Flow = MutableSharedFlow(), ) { entry { ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) @@ -77,30 +73,9 @@ fun EntryProviderScope.contactsGraph( } @Composable -private fun ContactsEntryContent( +expect fun ContactsEntryContent( backStack: NavBackStack, scrollToTopEvents: Flow, initialContactKey: String? = null, initialMessage: String = "", -) { - val uiViewModel: UIViewModel = koinViewModel() - val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() - val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() - val contactsViewModel = koinViewModel() - val messageViewModel = koinViewModel() - initialContactKey?.let { messageViewModel.setContactKey(it) } - - AdaptiveContactsScreen( - backStack = backStack, - contactsViewModel = contactsViewModel, - messageViewModel = messageViewModel, - scrollToTopEvents = scrollToTopEvents, - sharedContactRequested = sharedContactRequested, - requestChannelSet = requestChannelSet, - onHandleScannedUri = uiViewModel::handleScannedUri, - onClearSharedContactRequested = uiViewModel::clearSharedContactRequested, - onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl, - initialContactKey = initialContactKey, - initialMessage = initialMessage, - ) -} +) diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt similarity index 78% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index 318a6431f..00814a3a8 100644 --- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.messaging.ui.contact -import androidx.activity.compose.PredictiveBackHandler import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold @@ -29,7 +28,10 @@ import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import kotlinx.coroutines.CancellationException +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 @@ -44,6 +46,7 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.icon.Conversations import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.messaging.MessageScreen +import org.meshtastic.feature.messaging.MessageViewModel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact @@ -52,8 +55,8 @@ import org.meshtastic.proto.SharedContact @Composable fun AdaptiveContactsScreen( backStack: NavBackStack, - contactsViewModel: org.meshtastic.feature.messaging.ui.contact.ContactsViewModel, - messageViewModel: org.meshtastic.feature.messaging.MessageViewModel, + contactsViewModel: ContactsViewModel, + messageViewModel: MessageViewModel, scrollToTopEvents: Flow, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, @@ -62,6 +65,7 @@ fun AdaptiveContactsScreen( onClearRequestChannelUrl: () -> Unit, initialContactKey: String? = null, initialMessage: String = "", + detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null, ) { val navigator = rememberListDetailPaneScaffoldNavigator() val scope = rememberCoroutineScope() @@ -95,14 +99,18 @@ fun AdaptiveContactsScreen( } } - PredictiveBackHandler( - enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail, - ) { progress -> - try { - progress.collect { /* Predictive back progress could be used here to drive UI if scaffold supported it */ } - handleBack() - } catch (_: CancellationException) { - // Gesture cancelled + 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 } } @@ -154,14 +162,18 @@ fun AdaptiveContactsScreen( AnimatedPane { navigator.currentDestination?.contentKey?.let { contactKey -> key(contactKey) { - MessageScreen( - contactKey = contactKey, - message = if (contactKey == initialContactKey) initialMessage else "", - viewModel = messageViewModel, - navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, - navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) }, - onNavigateBack = handleBack, - ) + 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( diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt similarity index 97% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index 028b5be82..8be27f165 100644 --- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -49,16 +49,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey -import com.google.accompanist.permissions.ExperimentalPermissionsApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -66,7 +63,6 @@ import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.model.Contact import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.util.TimeConstants @@ -108,12 +104,11 @@ import org.meshtastic.core.ui.icon.SelectAll import org.meshtastic.core.ui.icon.VolumeMuteTwoTone import org.meshtastic.core.ui.icon.VolumeUpTwoTone import org.meshtastic.core.ui.qr.ScannedQrCodeDialog -import org.meshtastic.core.ui.util.showToast +import org.meshtastic.core.ui.util.rememberShowToastResource import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact import kotlin.time.Duration.Companion.days -@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList") @Composable fun ContactsScreen( @@ -124,13 +119,13 @@ fun ContactsScreen( onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, viewModel: ContactsViewModel, - onClickNodeChip: (Int) -> Unit = {}, - onNavigateToMessages: (String) -> Unit = {}, - onNavigateToNodeDetails: (Int) -> Unit = {}, - scrollToTopEvents: Flow? = null, - activeContactKey: String? = null, + onClickNodeChip: (Int) -> Unit, + onNavigateToMessages: (String) -> Unit, + onNavigateToNodeDetails: (Int) -> Unit, + scrollToTopEvents: Flow?, + activeContactKey: String?, ) { - val context = LocalContext.current + val showToast = rememberShowToastResource() val scope = rememberCoroutineScope() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() @@ -258,8 +253,8 @@ fun ContactsScreen( MeshtasticImportFAB( sharedContact = sharedContactRequested, onImport = { uriString -> - onHandleScannedUri(uriString.toUri().toMeshtasticUri()) { - scope.launch { context.showToast(Res.string.channel_invalid) } + onHandleScannedUri(MeshtasticUri(uriString)) { + scope.launch { showToast(Res.string.channel_invalid) } } }, onShareChannels = onNavigateToShare, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index def86b6dd..58f16af1f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -21,13 +21,13 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.DataPacket @@ -189,17 +189,17 @@ class ContactsViewModel( fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = - viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } + viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) } - fun markAllAsRead() = viewModelScope.launch(Dispatchers.IO) { packetRepository.clearAllUnreadCounts() } + fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() } fun setMuteUntil(contacts: List, until: Long) = - viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } + viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) } fun getContactSettings() = packetRepository.getContactSettings() fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } } /** diff --git a/feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt new file mode 100644 index 000000000..d9746d891 --- /dev/null +++ b/feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.messaging.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.ui.component.ScrollToTopEvent + +@Composable +actual fun ContactsEntryContent( + backStack: NavBackStack, + scrollToTopEvents: Flow, + initialContactKey: String?, + initialMessage: String, +) { + // TODO: Implement iOS contacts screen +} diff --git a/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt b/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt new file mode 100644 index 000000000..182b79276 --- /dev/null +++ b/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.messaging.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.Flow +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.ui.component.ScrollToTopEvent +import org.meshtastic.feature.messaging.MessageScreen +import org.meshtastic.feature.messaging.MessageViewModel +import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen +import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel + +@Composable +actual fun ContactsEntryContent( + backStack: NavBackStack, + scrollToTopEvents: Flow, + initialContactKey: String?, + initialMessage: String, +) { + val viewModel: ContactsViewModel = koinViewModel() + AdaptiveContactsScreen( + backStack = backStack, + contactsViewModel = viewModel, + messageViewModel = koinViewModel(), // Used for desktop detail pane + scrollToTopEvents = scrollToTopEvents, + sharedContactRequested = null, + requestChannelSet = null, + onHandleScannedUri = { _, _ -> }, + onClearSharedContactRequested = {}, + onClearRequestChannelUrl = {}, + initialContactKey = initialContactKey, + initialMessage = initialMessage, + detailPaneCustom = { contactKey -> + val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey") + MessageScreen( + contactKey = contactKey, + message = if (contactKey == initialContactKey) initialMessage else "", + viewModel = messageViewModel, + navigateToNodeDetails = { + backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) + }, + navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) }, + onNavigateBack = { backStack.removeLastOrNull() }, + ) + }, + ) +} diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index a52d4d13e..e5eb00bd5 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -70,14 +70,14 @@ private sealed interface NodeDetailOverlay { } @Composable -fun NodeDetailScreen( +actual fun NodeDetailScreen( nodeId: Int, - modifier: Modifier = Modifier, + modifier: Modifier, viewModel: NodeDetailViewModel, - navigateToMessages: (String) -> Unit = {}, - onNavigate: (Route) -> Unit = {}, - onNavigateUp: () -> Unit = {}, - compassViewModel: CompassViewModel? = null, + navigateToMessages: (String) -> Unit, + onNavigate: (Route) -> Unit, + onNavigateUp: () -> Unit, + compassViewModel: CompassViewModel?, ) { LaunchedEffect(nodeId) { viewModel.start(nodeId) } diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt index 78cc07fa8..2ed2fa7cd 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt @@ -102,7 +102,7 @@ private fun ActionButtons( @Suppress("LongMethod") @Composable -fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { +actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt new file mode 100644 index 000000000..109115492 --- /dev/null +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.navigation + +import androidx.compose.runtime.Composable +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.meshtastic.feature.node.metrics.MetricsViewModel +import org.meshtastic.feature.node.metrics.TracerouteMapScreen as AndroidTracerouteMapScreen + +@Composable +actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) { + val metricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) } + metricsViewModel.setNodeId(destNum) + + AndroidTracerouteMapScreen( + metricsViewModel = metricsViewModel, + requestId = requestId, + logUuid = logUuid, + onNavigateUp = onNavigateUp, + ) +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index 2a3584321..c261b7aab 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.bearing +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.latLongToMeter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds @@ -119,7 +120,7 @@ class CompassViewModel( val bearingDegrees = calculateBearing(locationState, target) val trueHeading = applyTrueNorthCorrection(headingState.heading, locationState) val distanceText = distanceMeters?.toDistanceString(current.displayUnits) - val bearingText = bearingDegrees?.let { BEARING_FORMAT.format(it) } + val bearingText = bearingDegrees?.let { formatString(BEARING_FORMAT, it) } val isAligned = isAligned(trueHeading, bearingDegrees) val lastUpdateText = targetPositionTimeSec?.let { formatElapsed(it) } val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt index fc8a5ad5c..7b42dd374 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.compass_bearing import org.meshtastic.core.resources.compass_bearing_na @@ -71,6 +72,7 @@ import org.meshtastic.core.resources.exchange_position import org.meshtastic.core.resources.last_position_update import org.meshtastic.feature.node.compass.CompassUiState import org.meshtastic.feature.node.compass.CompassWarning +import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin @@ -126,7 +128,7 @@ fun CompassSheetContent( Text( text = uiState.errorRadiusText?.let { radius -> - val angle = uiState.angularErrorDeg?.let { "%.0f°".format(it) } ?: "?" + val angle = uiState.angularErrorDeg?.let { formatString("%.0f°", it) } ?: "?" stringResource(Res.string.compass_uncertainty, radius, angle) } ?: stringResource(Res.string.compass_uncertainty_unknown), style = MaterialTheme.typography.bodyMedium, @@ -279,7 +281,7 @@ private fun CompassDial( else -> 1.dp.toPx() } - val angle = Math.toRadians(deg.toDouble()) + val angle = (deg * PI / 180.0) val outer = Offset(center.x + radius * sin(angle).toFloat(), center.y - radius * cos(angle).toFloat()) val inner = Offset( @@ -310,7 +312,7 @@ private fun CompassDial( ) for ((label, deg, color) in cardinals) { - val angle = Math.toRadians(deg.toDouble()) + val angle = (deg * PI / 180.0) val x = center.x + cardinalRadius * sin(angle).toFloat() val y = center.y - cardinalRadius * cos(angle).toFloat() @@ -327,7 +329,7 @@ private fun CompassDial( // Degree labels val degRadius = radius * 0.72f for (d in 0 until 360 step 30) { - val angle = Math.toRadians(d.toDouble()) + val angle = (d * PI / 180.0) val x = center.x + degRadius * sin(angle).toFloat() val y = center.y - degRadius * cos(angle).toFloat() @@ -363,8 +365,8 @@ private fun CompassDial( // Cone edge lines for clarity val edgeRadius = arcRadius - val startRad = Math.toRadians(startAngleNorth.toDouble()) - val endRad = Math.toRadians((startAngleNorth + sweep).toDouble()) + val startRad = (startAngleNorth * PI / 180.0) + val endRad = ((startAngleNorth + sweep) * PI / 180.0) val startEnd = Offset( center.x + edgeRadius * sin(startRad).toFloat(), @@ -376,7 +378,7 @@ private fun CompassDial( drawLine(color = faint, start = center, end = endEnd, strokeWidth = 6f, cap = StrokeCap.Round) } if (bearingForDraw != null) { - val angle = Math.toRadians(bearingForDraw.toDouble()) + val angle = (bearingForDraw * PI / 180.0) val dot = Offset( center.x + (radius * 0.95f) * sin(angle).toFloat(), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index a72fc7c0e..95291e07c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.Base64Factory +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime @@ -261,7 +262,7 @@ private fun SignalRow(node: Node) { if (node.snr != Float.MAX_VALUE) { InfoItem( label = stringResource(Res.string.snr), - value = "%.1f dB".format(node.snr), + value = formatString("%.1f dB", node.snr), icon = MeshtasticIcons.ChannelUtilization, modifier = Modifier.weight(1f), ) @@ -271,7 +272,7 @@ private fun SignalRow(node: Node) { if (node.rssi != Int.MAX_VALUE) { InfoItem( label = stringResource(Res.string.rssi), - value = "%d dBm".format(node.rssi), + value = formatString("%d dBm", node.rssi), icon = MeshtasticIcons.ChannelUtilization, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index ba857744c..15d317cae 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.isUnmessageableRole @@ -256,14 +257,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col icon = MeshtasticIcons.ChannelUtilization, contentDescription = stringResource(Res.string.channel_utilization), label = stringResource(Res.string.channel_utilization), - text = "%.1f%%".format(thatNode.deviceMetrics.channel_utilization), + text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization), contentColor = contentColor, ) IconInfo( icon = MeshtasticIcons.AirUtilization, contentDescription = stringResource(Res.string.air_utilization), label = stringResource(Res.string.air_utilization), - text = "%.1f%%".format(thatNode.deviceMetrics.air_util_tx), + text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx), contentColor = contentColor, ) } @@ -318,26 +319,28 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.temperature ?: 0f) != 0f) { val temp = if (tempInFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f)) + formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f)) } else { - "%.1f°C".format(env.temperature ?: 0f) + formatString("%.1f°C", env.temperature ?: 0f) } items.add { TemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.relative_humidity ?: 0f) != 0f) { - items.add { HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) } + items.add { + HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor) + } } if ((env.barometric_pressure ?: 0f) != 0f) { items.add { - PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor) + PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor) } } if ((env.soil_temperature ?: 0f) != 0f) { val temp = if (tempInFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f)) + formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f)) } else { - "%.1f°C".format(env.soil_temperature ?: 0f) + formatString("%.1f°C", env.soil_temperature ?: 0f) } items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) } } @@ -347,7 +350,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.voltage ?: 0f) != 0f) { items.add { PowerInfo( - value = "%.2fV".format(env.voltage ?: 0f), + value = formatString("%.2fV", env.voltage ?: 0f), label = stringResource(Res.string.voltage), contentColor = contentColor, ) @@ -356,7 +359,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.current ?: 0f) != 0f) { items.add { PowerInfo( - value = "%.1fmA".format(env.current ?: 0f), + value = formatString("%.1fmA", env.current ?: 0f), label = stringResource(Res.string.current), contentColor = contentColor, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt index 4b38e476d..2ec8c9d50 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.node.detail import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -28,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController @@ -60,7 +60,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon override val lastRequestNeighborTimes: StateFlow> = _lastRequestNeighborTimes.asStateFlow() override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { Logger.i { "Requesting UserInfo for '$destNum'" } radioController.requestUserInfo(destNum) _effects.emit( @@ -72,7 +72,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon } override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { Logger.i { "Requesting NeighborInfo for '$destNum'" } val packetId = radioController.getPacketId() radioController.requestNeighborInfo(packetId, destNum) @@ -86,7 +86,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon } override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { Logger.i { "Requesting position for '$destNum'" } radioController.requestPosition(destNum, position) _effects.emit( @@ -98,7 +98,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon } override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { Logger.i { "Requesting telemetry for '$destNum'" } val packetId = radioController.getPacketId() radioController.requestTelemetry(packetId, destNum, type.ordinal) @@ -121,7 +121,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon } override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { Logger.i { "Requesting traceroute for '$destNum'" } val packetId = radioController.getPacketId() radioController.requestTraceroute(packetId, destNum) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt new file mode 100644 index 000000000..852a8c2e0 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.detail + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.meshtastic.core.navigation.Route +import org.meshtastic.feature.node.compass.CompassViewModel + +@Composable +expect fun NodeDetailScreen( + nodeId: Int, + modifier: Modifier = Modifier, + viewModel: NodeDetailViewModel, + navigateToMessages: (String) -> Unit = {}, + onNavigate: (Route) -> Unit = {}, + onNavigateUp: () -> Unit = {}, + compassViewModel: CompassViewModel? = null, +) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index b643d701d..436954201 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -18,10 +18,10 @@ package org.meshtastic.feature.node.detail import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction @@ -59,7 +59,7 @@ constructor( } open fun removeNode(scope: CoroutineScope, nodeNum: Int) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { Logger.i { "Removing node '$nodeNum'" } val packetId = radioController.getPacketId() radioController.removeByNodenum(packetId, nodeNum) @@ -80,7 +80,7 @@ constructor( } open fun ignoreNode(scope: CoroutineScope, node: Node) { - scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) } + scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) } } open fun requestMuteNode(scope: CoroutineScope, node: Node) { @@ -96,7 +96,7 @@ constructor( } open fun muteNode(scope: CoroutineScope, node: Node) { - scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) } + scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) } } open fun requestFavoriteNode(scope: CoroutineScope, node: Node) { @@ -115,11 +115,11 @@ constructor( } open fun favoriteNode(scope: CoroutineScope, node: Node) { - scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) } + scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) } } open fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { try { nodeRepository.setNodeNotes(nodeNum, notes) } catch (ex: Exception) { diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt similarity index 96% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 205d56f48..17d2934a0 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.Flow @@ -59,7 +58,6 @@ import org.meshtastic.core.ui.component.MeshtasticImportFAB import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.smartScrollToTop import org.meshtastic.core.ui.qr.ScannedQrCodeDialog -import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.node.component.NodeContextMenu import org.meshtastic.feature.node.component.NodeFilterTextField import org.meshtastic.feature.node.component.NodeItem @@ -74,7 +72,7 @@ fun NodeListScreen( scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, ) { - val context = LocalContext.current + val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() val state by viewModel.nodesUiState.collectAsStateWithLifecycle() @@ -125,9 +123,7 @@ fun NodeListScreen( MeshtasticImportFAB( sharedContact = sharedContact, onImport = { uriString -> - viewModel.handleScannedUri(uriString) { - scope.launch { context.showToast(Res.string.channel_invalid) } - } + viewModel.handleScannedUri(uriString) { scope.launch { showToast(Res.string.channel_invalid) } } }, onDismissSharedContact = { viewModel.setSharedContactRequested(null) }, isContactContext = true, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index eca12df89..03407da05 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -61,6 +61,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.formatUptime @@ -256,11 +257,11 @@ private fun DeviceMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> when (color.copy(alpha = 1f)) { - batteryColor -> percentValueTemplate.format(batteryLabel, value) - voltageColor -> voltageValueTemplate.format(voltageLabel, value) - chUtilColor -> percentValueTemplate.format(channelUtilizationLabel, value) - airUtilColor -> percentValueTemplate.format(airUtilizationLabel, value) - else -> numericValueTemplate.format(value) + batteryColor -> formatString(percentValueTemplate, batteryLabel, value) + voltageColor -> formatString(voltageValueTemplate, voltageLabel, value) + chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value) + airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value) + else -> formatString(numericValueTemplate, value) } }, ) @@ -366,7 +367,7 @@ private fun DeviceMetricsChart( if (leftLayer != null) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = batteryColor), - valueFormatter = { _, value, _ -> "%.0f%%".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.0f%%", value) }, ) } else { null @@ -375,7 +376,7 @@ private fun DeviceMetricsChart( if (rightLayer != null) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> "%.1f V".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, ) } else { null @@ -488,7 +489,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick Spacer(Modifier.width(4.dp)) Text( text = - percentValueTemplate.format( + formatString( + percentValueTemplate, channelUtilizationLabel, deviceMetrics.channel_utilization ?: 0f, ), @@ -502,7 +504,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick Spacer(Modifier.width(4.dp)) Text( text = - percentValueTemplate.format( + formatString( + percentValueTemplate, airUtilizationLabel, deviceMetrics.air_util_tx ?: 0f, ), @@ -513,7 +516,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick } Text( text = - labelValueTemplate.format( + formatString( + labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0), ), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index d7ccf869c..6470e24dc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -33,6 +33,7 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.baro_pressure import org.meshtastic.core.resources.humidity @@ -187,7 +188,7 @@ fun EnvironmentMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> val label = colorToLabel[color.copy(alpha = 1f)] ?: "" - "%s: %.1f".format(label, value) + formatString("%s: %.1f", label, value) }, ) @@ -229,7 +230,7 @@ fun EnvironmentMetricsChart( if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color), - valueFormatter = { _, value, _ -> "%.0f hPa".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.0f hPa", value) }, ) } else { null @@ -237,7 +238,7 @@ fun EnvironmentMetricsChart( endAxis = VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = endAxisColor), - valueFormatter = { _, value, _ -> "%.0f".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.0f", value) }, ), bottomAxis = HorizontalAxis.rememberBottom( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index bd212575c..1447a2f59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res @@ -146,7 +147,7 @@ private fun TemperatureDisplay( MetricIndicator(Environment.TEMPERATURE.color) Spacer(Modifier.width(4.dp)) Text( - text = textFormat.format(stringResource(Res.string.temperature), temperature), + text = formatString(textFormat, stringResource(Res.string.temperature), temperature), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -171,7 +172,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) Text( - text = "%s %.2f%%".format(stringResource(Res.string.humidity), humidity), + text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -184,7 +185,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.2f hPa".format(pressure), + text = formatString("%.2f hPa", pressure), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -214,7 +215,8 @@ private fun SoilMetricsDisplay( Spacer(Modifier.width(4.dp)) Text( text = - soilMoistureTextFormat.format( + formatString( + soilMoistureTextFormat, stringResource(Res.string.soil_moisture), soilMoistureValue, ), @@ -231,7 +233,8 @@ private fun SoilMetricsDisplay( Spacer(Modifier.width(4.dp)) Text( text = - soilTemperatureTextFormat.format( + formatString( + soilTemperatureTextFormat, stringResource(Res.string.soil_temperature), soilTemperature, ), @@ -258,7 +261,7 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) MetricIndicator(Environment.LUX.color) Spacer(Modifier.width(4.dp)) Text( - text = "%s %.0f lx".format(stringResource(Res.string.lux), luxValue), + text = formatString("%s %.0f lx", stringResource(Res.string.lux), luxValue), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -270,7 +273,7 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) MetricIndicator(Environment.UV_LUX.color) Spacer(Modifier.width(4.dp)) Text( - text = "%s %.0f UVlx".format(stringResource(Res.string.uv_lux), uvLuxValue), + text = formatString("%s %.0f UVlx", stringResource(Res.string.uv_lux), uvLuxValue), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -290,7 +293,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasVoltage) { val voltage = envMetrics.voltage!! Text( - text = "%s %.2f V".format(stringResource(Res.string.voltage), voltage), + text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -298,7 +301,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasCurrent) { val currentValue = envMetrics.current!! Text( - text = "%s %.2f mA".format(stringResource(Res.string.current), currentValue), + text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -332,7 +335,7 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe MetricIndicator(Environment.GAS_RESISTANCE.color) Spacer(Modifier.width(4.dp)) Text( - text = "%s %.2f Ohm".format(stringResource(Res.string.gas_resistance), gasResistance), + text = formatString("%s %.2f Ohm", stringResource(Res.string.gas_resistance), gasResistance), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -348,7 +351,7 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics if (!radiation.isNaN() && radiation > 0f) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - text = "%s %.2f µR/h".format(stringResource(Res.string.radiation), radiation), + text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 1a94e021a..4d18c00bf 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -43,6 +43,7 @@ import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog @@ -341,9 +342,7 @@ open class MetricsViewModel( val altitude = position.altitude val satsInView = position.sats_in_view val speed = position.ground_speed - // Kotlin string format is available in common code on 1.9.20+ via String.format, - // but we can just do basic string manipulation if needed. - val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5) + val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5) sink.writeUtf8( "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n", diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index b2b53a4ef..35f7c15ef 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -54,6 +54,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLa import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.formatUptime @@ -120,10 +121,10 @@ private fun PaxMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> when (color.copy(alpha = 1f)) { - bleColor -> "BLE: %.0f".format(value) - wifiColor -> "WiFi: %.0f".format(value) - paxColor -> "PAX: %.0f".format(value) - else -> "%.0f".format(value) + bleColor -> formatString("BLE: %.0f", value) + wifiColor -> formatString("WiFi: %.0f", value) + paxColor -> formatString("PAX: %.0f", value) + else -> formatString("%.0f", value) } }, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt index 4be39dcb2..d8eb46b0e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res @@ -86,13 +87,13 @@ fun PositionItem(compactWidth: Boolean, position: Position, system: Config.Displ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20) - PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20) + PositionText(formatString("%.5f", (position.latitude_i ?: 0) * DEG_D), WEIGHT_20) + PositionText(formatString("%.5f", (position.longitude_i ?: 0) * DEG_D), WEIGHT_20) PositionText(position.sats_in_view.toString(), WEIGHT_10) PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15) if (!compactWidth) { PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15) - PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15) + PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15) } PositionText(position.formatPositionTime(), WEIGHT_40) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt new file mode 100644 index 000000000..d10793251 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.runtime.Composable + +@Composable expect fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index e01315ccf..9c1c9ba0e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -62,6 +62,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 @@ -201,9 +202,9 @@ private fun PowerMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> when (color.copy(alpha = 1f)) { - currentColor -> "Current: %.0f mA".format(value) - voltageColor -> "Voltage: %.1f V".format(value) - else -> "%.1f".format(value) + currentColor -> formatString("Current: %.0f mA", value) + voltageColor -> formatString("Voltage: %.1f V", value) + else -> formatString("%.1f", value) } }, ) @@ -275,7 +276,7 @@ private fun PowerMetricsChart( if (currentData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = currentColor), - valueFormatter = { _, value, _ -> "%.0f mA".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.0f mA", value) }, ) } else { null @@ -284,7 +285,7 @@ private fun PowerMetricsChart( if (voltageData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> "%.1f V".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, ) } else { null @@ -372,7 +373,7 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current MetricIndicator(PowerMetric.VOLTAGE.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.2fV".format(voltage), + text = formatString("%.2fV", voltage), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -381,7 +382,7 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current MetricIndicator(PowerMetric.CURRENT.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.1fmA".format(current), + text = formatString("%.1fmA", current), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index d6b99a9a9..c170d6d16 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -56,6 +56,7 @@ import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProdu import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.rssi @@ -182,9 +183,9 @@ private fun SignalMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> if (color.copy(alpha = 1f) == rssiColor) { - "RSSI: %.0f dBm".format(value) + formatString("RSSI: %.0f dBm", value) } else { - "SNR: %.1f dB".format(value) + formatString("SNR: %.1f dB", value) } }, ) @@ -226,7 +227,7 @@ private fun SignalMetricsChart( if (rssiData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = rssiColor), - valueFormatter = { _, value, _ -> "%.0f dBm".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) }, ) } else { null @@ -235,7 +236,7 @@ private fun SignalMetricsChart( if (snrData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = snrColor), - valueFormatter = { _, value, _ -> "%.1f dB".format(value) }, + valueFormatter = { _, value, _ -> formatString("%.1f dB", value) }, ) } else { null @@ -296,14 +297,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli MetricIndicator(SignalMetric.RSSI.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.0f dBm".format(meshPacket.rx_rssi.toFloat()), + text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), style = MaterialTheme.typography.labelLarge, ) Spacer(Modifier.width(12.dp)) MetricIndicator(SignalMetric.SNR.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.1f dB".format(meshPacket.rx_snr), + text = formatString("%.1f dB", meshPacket.rx_snr), style = MaterialTheme.typography.labelLarge, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 37a464ec5..e5321773b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -45,6 +45,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC @@ -163,7 +164,8 @@ fun TracerouteLogScreen( statusYellow = statusYellow, statusOrange = statusOrange, ) - val durationText = stringResource(Res.string.traceroute_duration, "%.1f".format(seconds)) + val durationText = + stringResource(Res.string.traceroute_duration, formatString("%.1f", seconds)) buildAnnotatedString { append(annotatedBase) append("\n\n$durationText") diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt similarity index 91% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index b38d4dbf3..249d31b99 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.node.navigation -import androidx.activity.compose.BackHandler import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold @@ -30,12 +29,16 @@ 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 import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ChannelsRoutes 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.EmptyDetailPlaceholder @@ -55,6 +58,7 @@ fun AdaptiveNodeListScreen( backStack: NavBackStack, scrollToTopEvents: Flow, initialNodeId: Int? = null, + onNavigate: (Route) -> Unit = {}, onNavigateToMessages: (String) -> Unit = {}, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() @@ -78,7 +82,13 @@ fun AdaptiveNodeListScreen( } } - BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() } + val navState = rememberNavigationEventState(NavigationEventInfo.None) + NavigationBackHandler( + state = navState, + isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail, + onBackCancelled = {}, + onBackCompleted = { handleBack() }, + ) LaunchedEffect(initialNodeId) { if (initialNodeId != null) { @@ -134,7 +144,7 @@ fun AdaptiveNodeListScreen( viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, navigateToMessages = onNavigateToMessages, - onNavigate = { route -> backStack.add(route) }, + onNavigate = onNavigate, onNavigateUp = handleBack, ) } diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt similarity index 94% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 9c29e1d5c..e7729a983 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -32,6 +32,7 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import org.jetbrains.compose.resources.StringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -60,18 +61,19 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen import org.meshtastic.feature.node.metrics.PowerMetricsScreen import org.meshtastic.feature.node.metrics.SignalMetricsScreen import org.meshtastic.feature.node.metrics.TracerouteLogScreen -import org.meshtastic.feature.node.metrics.TracerouteMapScreen import kotlin.reflect.KClass +@Suppress("LongMethod") fun EntryProviderScope.nodesGraph( backStack: NavBackStack, - scrollToTopEvents: Flow, - nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit, + scrollToTopEvents: Flow = MutableSharedFlow(), + nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> }, ) { entry { AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, + onNavigate = { backStack.add(it) }, onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, ) } @@ -80,6 +82,7 @@ fun EntryProviderScope.nodesGraph( AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, + onNavigate = { backStack.add(it) }, onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, ) } @@ -98,6 +101,7 @@ fun EntryProviderScope.nodeDetailGraph( backStack = backStack, scrollToTopEvents = scrollToTopEvents, initialNodeId = args.destNum, + onNavigate = { backStack.add(it) }, onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, ) } @@ -107,6 +111,7 @@ fun EntryProviderScope.nodeDetailGraph( backStack = backStack, scrollToTopEvents = scrollToTopEvents, initialNodeId = args.destNum, + onNavigate = { backStack.add(it) }, onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, ) } @@ -134,12 +139,8 @@ fun EntryProviderScope.nodeDetailGraph( } entry { args -> - val metricsViewModel = - koinViewModel(key = "metrics-${args.destNum}") { parametersOf(args.destNum) } - metricsViewModel.setNodeId(args.destNum) - TracerouteMapScreen( - metricsViewModel = metricsViewModel, + destNum = args.destNum, requestId = args.requestId, logUuid = args.logUuid, onNavigateUp = { backStack.removeLastOrNull() }, @@ -185,6 +186,9 @@ private inline fun EntryProviderScope.addNodeDetailS } } +/** Expect declaration for the platform-specific traceroute map screen. */ +@Composable expect fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) + enum class NodeDetailRoute( val title: StringResource, val routeClass: KClass, diff --git a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt new file mode 100644 index 000000000..33925cbc9 --- /dev/null +++ b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.detail + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.meshtastic.core.navigation.Route +import org.meshtastic.feature.node.compass.CompassViewModel + +@Composable +actual fun NodeDetailScreen( + nodeId: Int, + modifier: Modifier, + viewModel: NodeDetailViewModel, + navigateToMessages: (String) -> Unit, + onNavigate: (Route) -> Unit, + onNavigateUp: () -> Unit, + compassViewModel: CompassViewModel?, +) { + // TODO: Implement iOS node detail screen +} diff --git a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt new file mode 100644 index 000000000..d8120065e --- /dev/null +++ b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.runtime.Composable + +@Composable +actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { + // TODO: Implement iOS position log screen +} diff --git a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt new file mode 100644 index 000000000..686e84948 --- /dev/null +++ b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.navigation + +import androidx.compose.runtime.Composable + +@Composable +actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) { + // TODO: Implement iOS traceroute map screen +} diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt new file mode 100644 index 000000000..0076ecf39 --- /dev/null +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.detail + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.meshtastic.core.navigation.Route +import org.meshtastic.feature.node.compass.CompassViewModel +import org.meshtastic.feature.node.component.NodeMenuAction +import org.meshtastic.feature.node.model.NodeDetailAction + +@Composable +actual fun NodeDetailScreen( + nodeId: Int, + modifier: Modifier, + viewModel: NodeDetailViewModel, + navigateToMessages: (String) -> Unit, + onNavigate: (Route) -> Unit, + onNavigateUp: () -> Unit, + compassViewModel: CompassViewModel?, +) { + LaunchedEffect(nodeId) { viewModel.start(nodeId) } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Desktop just renders the NodeDetailContent directly. Overlays like Compass are no-ops. + NodeDetailContent( + uiState = uiState, + modifier = modifier, + onAction = { action -> + when (action) { + is NodeDetailAction.Navigate -> onNavigate(action.route) + is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action) + is NodeDetailAction.HandleNodeMenuAction -> { + when (val menuAction = action.action) { + is NodeMenuAction.DirectMessage -> { + val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) + navigateToMessages(route) + } + is NodeMenuAction.Remove -> { + viewModel.handleNodeMenuAction(menuAction) + onNavigateUp() + } + else -> viewModel.handleNodeMenuAction(menuAction) + } + } + else -> {} + } + }, + onFirmwareSelect = { /* No-op on desktop for now */ }, + onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) }, + ) +} diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt new file mode 100644 index 000000000..48f7b2989 --- /dev/null +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Position Log", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt new file mode 100644 index 000000000..c7129dd73 --- /dev/null +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) { + // Desktop placeholder for now + PlaceholderScreen(name = "Traceroute Map") +} + +@Composable +internal fun PlaceholderScreen(name: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = name, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/settings/detekt-baseline.xml b/feature/settings/detekt-baseline.xml index 348ed6629..9e431e4c4 100644 --- a/feature/settings/detekt-baseline.xml +++ b/feature/settings/detekt-baseline.xml @@ -2,12 +2,10 @@ - CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket) LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) - LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$fun setResponseStateLoading(route: Enum<*>) LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt new file mode 100644 index 000000000..4b9cf369d --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.navigation + +import org.meshtastic.core.navigation.SettingsRoutes + +actual fun getAboutLibrariesJson(): String = + SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt new file mode 100644 index 000000000..6ae709459 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.navigation + +import androidx.compose.runtime.Composable +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( + settingsViewModel: SettingsViewModel, + radioConfigViewModel: RadioConfigViewModel, + onClickNodeChip: (Int) -> Unit, + onNavigate: (Route) -> Unit, +) { + SettingsScreen( + settingsViewModel = settingsViewModel, + viewModel = radioConfigViewModel, + onClickNodeChip = onClickNodeChip, + onNavigate = onNavigate, + ) +} + +@Composable +actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + AndroidDeviceConfigScreen(viewModel = viewModel, onBack = onBack) +} + +@Composable +actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + AndroidPositionConfigScreen(viewModel = viewModel, onBack = onBack) +} + +@Composable +actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + AndroidSecurityConfigScreen(viewModel = viewModel, onBack = onBack) +} + +@Composable +actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + AndroidExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index c3410f33d..adb962cbb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowInstant import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.MeshLog @@ -277,7 +278,7 @@ class DebugViewModel( } } - suspend fun loadLogsForExport(): ImmutableList = withContext(Dispatchers.IO) { + suspend fun loadLogsForExport(): ImmutableList = withContext(ioDispatcher) { val unbounded = meshLogRepository.getAllLogsUnbounded().first() val logs = if (unbounded.isEmpty()) meshLogRepository.getAllLogs().first() else unbounded toUiState(logs) @@ -405,7 +406,7 @@ class DebugViewModel( ) } - fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() } + fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt new file mode 100644 index 000000000..b0610a58a --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.navigation + +expect fun getAboutLibrariesJson(): String diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt similarity index 88% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 86b5a228c..c909e38e8 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -14,8 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("Wrapping", "SpacingAroundColon") - package org.meshtastic.feature.settings.navigation import androidx.compose.runtime.Composable @@ -33,7 +31,6 @@ import org.meshtastic.feature.settings.AboutScreen import org.meshtastic.feature.settings.AdministrationScreen import org.meshtastic.feature.settings.DeviceConfigurationScreen import org.meshtastic.feature.settings.ModuleConfigurationScreen -import org.meshtastic.feature.settings.SettingsScreen import org.meshtastic.feature.settings.SettingsViewModel import org.meshtastic.feature.settings.debugging.DebugScreen import org.meshtastic.feature.settings.debugging.DebugViewModel @@ -48,19 +45,15 @@ 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.DeviceConfigScreen import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen -import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen 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.PositionConfigScreen 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.SecurityConfigScreen import org.meshtastic.feature.settings.radio.component.SerialConfigScreen import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen @@ -70,9 +63,8 @@ import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigSc import org.meshtastic.feature.settings.radio.component.UserConfigScreen import kotlin.reflect.KClass -@PublishedApi @Composable -internal fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewModel { +fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewModel { val viewModel = koinViewModel() LaunchedEffect(backStack) { val destNum = @@ -88,23 +80,21 @@ internal fun getRadioConfigViewModel(backStack: NavBackStack): RadioConf @Suppress("LongMethod", "CyclomaticComplexMethod") fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { entry { - SettingsScreen( - settingsViewModel = koinViewModel(), - viewModel = getRadioConfigViewModel(backStack), + SettingsMainScreen( + settingsViewModel = koinViewModel(), + radioConfigViewModel = getRadioConfigViewModel(backStack), onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, - ) { - backStack.add(it) - } + onNavigate = { backStack.add(it) }, + ) } entry { - SettingsScreen( - settingsViewModel = koinViewModel(), - viewModel = getRadioConfigViewModel(backStack), + SettingsMainScreen( + settingsViewModel = koinViewModel(), + radioConfigViewModel = getRadioConfigViewModel(backStack), onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, - ) { - backStack.add(it) - } + onNavigate = { backStack.add(it) }, + ) } entry { @@ -192,10 +182,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { } entry { - AboutScreen( - onNavigateUp = { backStack.removeLastOrNull() }, - jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" }, - ) + AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() }) } entry { @@ -204,6 +191,24 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { } } +/** Expect declaration for the platform-specific settings main screen. */ +@Composable +expect fun SettingsMainScreen( + settingsViewModel: SettingsViewModel, + radioConfigViewModel: RadioConfigViewModel, + onClickNodeChip: (Int) -> Unit, + onNavigate: (Route) -> Unit, +) + +/** Expect declarations for platform-specific config screens. */ +@Composable expect fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) + +@Composable expect fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) + +@Composable expect fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) + +@Composable expect fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) + fun EntryProviderScope.configComposable( route: KClass, backStack: NavBackStack, @@ -211,10 +216,3 @@ fun EntryProviderScope.configComposable( ) { addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) } } - -inline fun EntryProviderScope.configComposable( - backStack: NavBackStack, - noinline content: @Composable (RadioConfigViewModel) -> Unit, -) { - entry { content(getRadioConfigViewModel(backStack)) } -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index c3848aeeb..8039dc37d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.meshtastic.core.common.util.formatString import org.meshtastic.feature.settings.radio.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f @@ -72,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) { trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( - text = "%.0f%%".format(progress * PERCENTAGE_FACTOR), + text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt similarity index 71% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt index 36fd6f0d4..9497a9778 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -31,19 +30,15 @@ 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.LocalContext 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.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.barcode.rememberBarcodeScanner +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.extractWifiCredentials import org.meshtastic.core.model.util.handleMeshtasticUri -import org.meshtastic.core.model.util.toCommonUri -import org.meshtastic.core.nfc.NfcScannerEffect import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.advanced import org.meshtastic.core.resources.cancel @@ -56,7 +51,6 @@ import org.meshtastic.core.resources.ethernet_config import org.meshtastic.core.resources.ethernet_enabled import org.meshtastic.core.resources.ethernet_ip import org.meshtastic.core.resources.gateway -import org.meshtastic.core.resources.ip import org.meshtastic.core.resources.ipv4_mode import org.meshtastic.core.resources.network import org.meshtastic.core.resources.nfc_disabled @@ -81,7 +75,9 @@ import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard -import org.meshtastic.core.ui.util.openNfcSettings +import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider +import org.meshtastic.core.ui.util.LocalNfcScannerProvider +import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config @@ -89,12 +85,18 @@ import org.meshtastic.proto.Config private fun ScanErrorDialog(onDismiss: () -> Unit = {}) = MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss) +@Suppress("detekt:MagicNumber") +private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." + + "${(ipAddress shr 8) and 0xFF}." + + "${(ipAddress shr 16) and 0xFF}." + + "${(ipAddress shr 24) and 0xFF}" + +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { +fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onOpenNfcSettings: () -> Unit = {}) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val networkConfig = state.radioConfig.network ?: Config.NetworkConfig() val formState = rememberConfigState(initialValue = networkConfig) - val context = LocalContext.current var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) } if (showScanErrorDialog) { @@ -109,7 +111,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { message = stringResource(Res.string.nfc_disabled), confirmText = stringResource(Res.string.open_settings), onConfirm = { - context.openNfcSettings() + onOpenNfcSettings() showNfcDisabledDialog = false }, dismissText = stringResource(Res.string.cancel), @@ -120,7 +122,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { if (contents != null) { val handled = handleMeshtasticUri( - uri = contents.toUri().toCommonUri(), + uri = CommonUri.parse(contents), onChannel = {}, // No-op, not supported in network config onContact = {}, // No-op, not supported in network config ) @@ -136,8 +138,10 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } } - val barcodeScanner = rememberBarcodeScanner(onResult = onResult) - NfcScannerEffect(onResult = onResult, onNfcDisabled = { showNfcDisabledDialog = true }) + val barcodeScanner = LocalBarcodeScannerProvider.current(onResult) + if (LocalNfcScannerSupported.current) { + LocalNfcScannerProvider.current(onResult) { showNfcDisabledDialog = true } + } val focusManager = LocalFocusManager.current @@ -164,7 +168,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { if (wifiStatus.is_connected) { ListItem( text = stringResource(Res.string.wifi_ip), - supportingText = formatIpAddress(wifiStatus.ip_address), + supportingText = formatIpAddress(wifiStatus.ip_address ?: 0), trailingIcon = null, ) } @@ -173,7 +177,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { if (ethernetStatus.is_connected) { ListItem( text = stringResource(Res.string.ethernet_ip), - supportingText = formatIpAddress(ethernetStatus.ip_address), + supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0), trailingIcon = null, ) } @@ -182,17 +186,17 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } } } - if (state.metadata?.hasWifi == true) { - item { - TitledCard(title = stringResource(Res.string.wifi_config)) { - SwitchPreference( - title = stringResource(Res.string.wifi_enabled), - summary = stringResource(Res.string.config_network_wifi_enabled_summary), - checked = formState.value.wifi_enabled, - enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) + + item { + TitledCard(title = stringResource(Res.string.wifi_config)) { + SwitchPreference( + title = stringResource(Res.string.wifi_enabled), + summary = stringResource(Res.string.config_network_wifi_enabled_summary), + checked = formState.value.wifi_enabled, + onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) }, + enabled = state.connected, + ) + if (formState.value.wifi_enabled) { HorizontalDivider() EditTextPreference( title = stringResource(Res.string.ssid), @@ -232,31 +236,12 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { title = stringResource(Res.string.ethernet_enabled), summary = stringResource(Res.string.config_network_eth_enabled_summary), checked = formState.value.eth_enabled, - enabled = state.connected, onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) }, - containerColor = CardDefaults.cardColors().containerColor, - ) - } - } - } - - if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) { - item { - TitledCard(title = stringResource(Res.string.network)) { - SwitchPreference( - title = stringResource(Res.string.udp_enabled), - summary = stringResource(Res.string.config_network_udp_enabled_summary), - checked = formState.value.enabled_protocols == 1, enabled = state.connected, - onCheckedChange = { - formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0) - }, - containerColor = CardDefaults.cardColors().containerColor, ) } } } - item { TitledCard(title = stringResource(Res.string.advanced)) { EditTextPreference( @@ -264,7 +249,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { value = formState.value.ntp_server, maxSize = 32, // ntp_server max_size:33 enabled = state.connected, - isError = formState.value.ntp_server.isEmpty(), + isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), @@ -283,57 +268,63 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) }, ) HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.ipv4_mode), + SwitchPreference( + title = stringResource(Res.string.udp_enabled), + summary = stringResource(Res.string.config_network_udp_enabled_summary), + checked = formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, + onCheckedChange = { + formState.value = + formState.value.copy( + address_mode = + if (it) { + Config.NetworkConfig.AddressMode.STATIC + } else { + Config.NetworkConfig.AddressMode.DHCP + }, + ) + }, enabled = state.connected, - items = Config.NetworkConfig.AddressMode.entries.map { it to it.name }, - selectedItem = formState.value.address_mode, - onItemSelected = { formState.value = formState.value.copy(address_mode = it) }, - ) - HorizontalDivider() - val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config() - EditIPv4Preference( - title = stringResource(Res.string.ip), - value = ipv4.ip, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) }, - ) - HorizontalDivider() - EditIPv4Preference( - title = stringResource(Res.string.gateway), - value = ipv4.gateway, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) }, - ) - HorizontalDivider() - EditIPv4Preference( - title = stringResource(Res.string.subnet), - value = ipv4.subnet, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) }, - ) - HorizontalDivider() - EditIPv4Preference( - title = "DNS", - value = ipv4.dns, - enabled = - state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) }, ) + if (formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC) { + HorizontalDivider() + DropDownPreference( + title = stringResource(Res.string.ipv4_mode), + enabled = state.connected, + selectedItem = formState.value.address_mode, + onItemSelected = { formState.value = formState.value.copy(address_mode = it) }, + itemLabel = { it.name }, + ) + HorizontalDivider() + val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config() + EditIPv4Preference( + title = stringResource(Res.string.wifi_ip), + value = ipv4.ip, + enabled = state.connected, + onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) }, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) + HorizontalDivider() + EditIPv4Preference( + title = stringResource(Res.string.gateway), + value = ipv4.gateway, + enabled = state.connected, + onValueChanged = { + formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) + }, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) + HorizontalDivider() + EditIPv4Preference( + title = stringResource(Res.string.subnet), + value = ipv4.subnet, + enabled = state.connected, + onValueChanged = { + formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) + }, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) + } } } } } - -@Suppress("detekt:MagicNumber") -private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." + - "${(ipAddress shr 8) and 0xFF}." + - "${(ipAddress shr 16) and 0xFF}." + - "${(ipAddress shr 24) and 0xFF}" diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt index f20fd5f4f..ec8cb798d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close @@ -110,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress") Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "%.0f%%".format(progress * 100f), + text = formatString("%.0f%%", progress * 100f), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.secondary, ) diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt new file mode 100644 index 000000000..88e61055e --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.debugging + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberLogExporter(logsProvider: suspend () -> List): (fileName: String) -> Unit = + { _ -> + } diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt new file mode 100644 index 000000000..44f57ab8c --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.navigation + +actual fun getAboutLibrariesJson(): String { + return "" // TODO: Implement reading aboutlibraries.json on iOS +} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt new file mode 100644 index 000000000..9cb260a87 --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.navigation + +import androidx.compose.runtime.Composable +import org.meshtastic.core.navigation.Route +import org.meshtastic.feature.settings.SettingsViewModel +import org.meshtastic.feature.settings.radio.RadioConfigViewModel + +@Composable +actual fun SettingsMainScreen( + settingsViewModel: SettingsViewModel, + radioConfigViewModel: RadioConfigViewModel, + onClickNodeChip: (Int) -> Unit, + onNavigate: (Route) -> Unit, +) { + // TODO: Implement iOS settings main screen +} + +@Composable +actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + // TODO: Implement iOS device config screen +} + +@Composable +actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + // TODO: Implement iOS position config screen +} + +@Composable +actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + // TODO: Implement iOS security config screen +} + +@Composable +actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + // TODO: Implement iOS external notification config screen +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopDeviceConfigScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt similarity index 99% rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopDeviceConfigScreen.kt rename to feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt index 3314d6bb7..d17371701 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopDeviceConfigScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopDeviceConfigScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.desktop.ui.settings +package org.meshtastic.feature.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopExternalNotificationConfigScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt similarity index 99% rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopExternalNotificationConfigScreen.kt rename to feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt index 04771f043..b19ecdb21 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopExternalNotificationConfigScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopExternalNotificationConfigScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.desktop.ui.settings +package org.meshtastic.feature.settings import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopPositionConfigScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt similarity index 99% rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopPositionConfigScreen.kt rename to feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt index 8ad2ad52e..b137daa91 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopPositionConfigScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopPositionConfigScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.desktop.ui.settings +package org.meshtastic.feature.settings import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.CardDefaults diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSecurityConfigScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt similarity index 99% rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSecurityConfigScreen.kt rename to feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt index 76e3a3720..fd3418063 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSecurityConfigScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSecurityConfigScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.desktop.ui.settings +package org.meshtastic.feature.settings import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt similarity index 99% rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt rename to feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 833f377b0..4ac7f1b2f 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.desktop.ui.settings +package org.meshtastic.feature.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -71,7 +71,6 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.theme.MODE_DYNAMIC import org.meshtastic.core.ui.util.rememberShowToastResource -import org.meshtastic.feature.settings.SettingsViewModel import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt new file mode 100644 index 000000000..4b9cf369d --- /dev/null +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.navigation + +import org.meshtastic.core.navigation.SettingsRoutes + +actual fun getAboutLibrariesJson(): String = + SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt new file mode 100644 index 000000000..547c4f5c9 --- /dev/null +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.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 + +@Composable +actual fun SettingsMainScreen( + settingsViewModel: SettingsViewModel, + radioConfigViewModel: RadioConfigViewModel, + onClickNodeChip: (Int) -> Unit, + onNavigate: (Route) -> Unit, +) { + DesktopSettingsScreen( + settingsViewModel = settingsViewModel, + radioConfigViewModel = radioConfigViewModel, + onNavigate = onNavigate, + ) +} + +@Composable +actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + DesktopDeviceConfigScreen(viewModel = viewModel, onBack = onBack) +} + +@Composable +actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + DesktopPositionConfigScreen(viewModel = viewModel, onBack = onBack) +} + +@Composable +actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + DesktopSecurityConfigScreen(viewModel = viewModel, onBack = onBack) +} + +@Composable +actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { + DesktopExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack) +} diff --git a/fix_dispatchers.py b/fix_dispatchers.py new file mode 100644 index 000000000..c8fab24d0 --- /dev/null +++ b/fix_dispatchers.py @@ -0,0 +1,63 @@ + +import os +import re + +files = [ + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt", + "core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt", + "feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt", + "feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt", + "feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt", + "feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt", + "feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt", + "feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt", + "feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt", + "feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt" +] + +for file_path in files: + if not os.path.exists(file_path): + print(f"File not found: {file_path}") + continue + + with open(file_path, 'r') as f: + content = f.read() + + # Replace Dispatchers.IO with ioDispatcher + new_content = re.sub(r'\bDispatchers\.IO\b', 'ioDispatcher', content) + new_content = re.sub(r'\bkotlinx\.coroutines\.Dispatchers\.IO\b', 'ioDispatcher', new_content) + + if new_content == content: + print(f"No changes in {file_path}") + continue + + # Add import if missing + if 'import org.meshtastic.core.common.util.ioDispatcher' not in new_content: + # Find the package line to insert after, or just after other imports + lines = new_content.splitlines() + inserted = False + for i, line in enumerate(lines): + if line.startswith('package '): + lines.insert(i + 2, 'import org.meshtastic.core.common.util.ioDispatcher') + inserted = True + break + if not inserted: + lines.insert(0, 'import org.meshtastic.core.common.util.ioDispatcher') + new_content = '\n'.join(lines) + '\n' + + # Check if Dispatchers is still used + if 'Dispatchers.' not in new_content: + new_content = re.sub(r'^import kotlinx\.coroutines\.Dispatchers\n', '', new_content, flags=re.MULTILINE) + + with open(file_path, 'w') as f: + f.write(new_content) + print(f"Fixed {file_path}") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3dbb6b7a6..04396c30b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ lifecycle = "2.10.0" jetbrains-lifecycle = "2.10.0" navigation = "2.9.7" navigation3 = "1.1.0-alpha04" +navigationevent = "1.0.1" paging = "3.4.2" room = "3.0.0-alpha01" savedstate = "1.4.0" @@ -107,6 +108,7 @@ androidx-navigation-common = { module = "androidx.navigation:navigation-common", # Both `jetbrains-navigation3-runtime` and `jetbrains-navigation3-ui` resolve to the same coordinate. jetbrains-navigation3-runtime = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } @@ -138,6 +140,7 @@ compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtim compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } +compose-multiplatform-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-multiplatform-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } compose-multiplatform-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-multiplatform" } compose-multiplatform-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version = "1.7.3" }