Refactor nav3 architecture and enhance adaptive layouts (#4944)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-27 09:43:44 -05:00
committed by GitHub
parent 3feec759a1
commit f2d09ff79d
29 changed files with 740 additions and 617 deletions
+3 -2
View File
@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). | | `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. | | `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. | | `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | | `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | | `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. | | `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -79,7 +79,8 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. - **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow. - **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. - **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`. - **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules. - **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
+3 -3
View File
@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | | `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. | | `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. | | `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | | `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | | `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. | | `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -64,7 +64,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable. - **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. - **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. - **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp. - **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer ### B. Logic & Data Layer
@@ -81,7 +81,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets. - **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
- **Concurrency:** Use Kotlin Coroutines and Flow. - **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. - **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`. - **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules. - **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
+2 -2
View File
@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | | `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. | | `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. | | `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | | `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | | `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. | | `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -79,7 +79,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. - **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow. - **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. - **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. - **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`. - **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`.
+1
View File
@@ -219,6 +219,7 @@ dependencies {
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.navigation) implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network) implementation(projects.core.network)
implementation(projects.core.nfc) implementation(projects.core.nfc)
implementation(projects.core.prefs) implementation(projects.core.prefs)
@@ -139,7 +139,7 @@ class MainActivity : ComponentActivity() {
ReportDrawnWhen { true } ReportDrawnWhen { true }
if (appIntroCompleted) { if (appIntroCompleted) {
MainScreen(uIViewModel = model) MainScreen()
} else { } else {
val introViewModel = koinViewModel<IntroViewModel>() val introViewModel = koinViewModel<IntroViewModel>()
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel) AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
@@ -174,7 +174,7 @@ class MainActivity : ComponentActivity() {
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp -> { destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel = val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel>(key = "metrics-$destNum") { koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
org.koin.core.parameter.parametersOf(destNum) org.koin.core.parameter.parametersOf(destNum)
} }
metricsViewModel.setNodeId(destNum) metricsViewModel.setNodeId(destNum)
@@ -19,30 +19,27 @@
package org.meshtastic.app.ui package org.meshtastic.app.ui
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.rememberMultiBackstack
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.must_update import org.meshtastic.core.resources.must_update
import org.meshtastic.core.ui.component.MeshtasticAppShell import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph
@@ -52,31 +49,27 @@ import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.navigation.settingsGraph import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.settings.radio.channel.channelsGraph
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable @Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) { fun MainScreen() {
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey) val viewModel: UIViewModel = koinViewModel()
val multiBackstack = rememberMultiBackstack(NodesRoutes.NodesGraph)
val backStack = multiBackstack.activeBackStack
AndroidAppVersionCheck(uIViewModel) AndroidAppVersionCheck(viewModel)
MeshtasticAppShell( MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
backStack = backStack, MeshtasticNavigationSuite(
uiViewModel = uIViewModel, multiBackstack = multiBackstack,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp), uiViewModel = viewModel,
) {
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
backStack = backStack,
uiViewModel = uIViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
val provider = val provider =
entryProvider<NavKey> { entryProvider<NavKey> {
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow) contactsGraph(backStack, viewModel.scrollToTopEventFlow)
nodesGraph( nodesGraph(
backStack = backStack, backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow, scrollToTopEvents = viewModel.scrollToTopEventFlow,
onHandleDeepLink = uIViewModel::handleDeepLink, onHandleDeepLink = viewModel::handleDeepLink,
) )
mapGraph(backStack) mapGraph(backStack)
channelsGraph(backStack) channelsGraph(backStack)
@@ -84,8 +77,8 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
settingsGraph(backStack) settingsGraph(backStack)
firmwareGraph(backStack) firmwareGraph(backStack)
} }
NavDisplay( MeshtasticNavDisplay(
backStack = backStack, multiBackstack = multiBackstack,
entryProvider = provider, entryProvider = provider,
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(), modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
) )
@@ -99,7 +92,6 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
// Check if the device is running an old app version
LaunchedEffect(connectionState, myNodeInfo) { LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.Connected) { if (connectionState == ConnectionState.Connected) {
myNodeInfo?.let { info -> myNodeInfo?.let { info ->
@@ -0,0 +1,89 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
/** Manages independent backstacks for multiple tabs. */
class MultiBackstack(val startTab: NavKey) {
var backStacks: Map<NavKey, NavBackStack<NavKey>> = emptyMap()
var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab)
private set
val activeBackStack: NavBackStack<NavKey>
get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found")
/** Switches to a new top-level tab route. */
fun navigateTopLevel(route: NavKey) {
val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route
if (currentTabRoute == rootKey) {
// Repressing the same tab resets its stack to just the root
activeBackStack.replaceAll(listOf(rootKey))
} else {
// Switching to a different tab
currentTabRoute = rootKey
}
}
/** Handles back navigation according to the "exit through home" pattern. */
fun goBack() {
val currentStack = activeBackStack
if (currentStack.size > 1) {
currentStack.removeLastOrNull()
return
}
// If we're at the root of a non-start tab, switch back to the start tab
if (currentTabRoute != startTab) {
currentTabRoute = startTab
}
}
/** Sets the active tab and replaces its stack with the provided route path. */
fun handleDeepLink(navKeys: List<NavKey>) {
val rootKey = navKeys.firstOrNull() ?: return
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
currentTabRoute = topLevel
val stack = backStacks[topLevel] ?: return
stack.replaceAll(navKeys)
}
}
/** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */
@Composable
fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack {
val stacks = mutableMapOf<NavKey, NavBackStack<NavKey>>()
TopLevelDestination.entries.forEach { dest ->
key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) }
}
val multiBackstack = remember { MultiBackstack(initialTab) }
multiBackstack.backStacks = stacks
return multiBackstack
}
@@ -19,16 +19,38 @@ package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
/** /**
* Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the * Replaces the last entry in the back stack with the given route. If the back stack is empty, it simply adds the route.
* root destination.
*/ */
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) { fun MutableList<NavKey>.replaceLast(route: NavKey) {
if (isNotEmpty()) { if (isNotEmpty()) {
this[0] = route if (this[lastIndex] != route) {
while (size > 1) { this[lastIndex] = route
removeAt(lastIndex)
} }
} else { } else {
add(route) add(route)
} }
} }
/**
* Replaces the entire back stack with the given routes in a way that minimizes structural changes and prevents the back
* stack from temporarily becoming empty.
*/
fun MutableList<NavKey>.replaceAll(routes: List<NavKey>) {
if (routes.isEmpty()) {
clear()
return
}
for (i in routes.indices) {
if (i < size) {
// Only mutate if the route actually changed, protecting Nav3's internal state matching.
if (this[i] != routes[i]) {
this[i] = routes[i]
}
} else {
add(routes[i])
}
}
while (size > routes.size) {
removeAt(lastIndex)
}
}
@@ -0,0 +1,114 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlin.test.Test
import kotlin.test.assertEquals
class MultiBackstackTest {
@Test
fun `navigateTopLevel to different tab preserves previous tab stack and activates new tab stack`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
multiBackstack.backStacks =
mapOf(TopLevelDestination.Nodes.route to nodesStack, TopLevelDestination.Map.route to mapStack)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
assertEquals(2, multiBackstack.activeBackStack.size)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(2, nodesStack.size)
}
@Test
fun `navigateTopLevel to same tab resets stack to root`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
assertEquals(2, multiBackstack.activeBackStack.size)
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
}
@Test
fun `goBack pops current stack if size is greater than 1`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
multiBackstack.goBack()
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
}
@Test
fun `goBack on root of non-start tab returns to start tab`() {
val startTab = TopLevelDestination.Connections.route
val multiBackstack = MultiBackstack(startTab)
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
val connectionsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
multiBackstack.backStacks =
mapOf(TopLevelDestination.Map.route to mapStack, TopLevelDestination.Connections.route to connectionsStack)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
multiBackstack.goBack()
assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
}
@Test
fun `handleDeepLink sets target tab and populates stack`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val settingsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Settings.route)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack)
val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoutes.About)
multiBackstack.handleDeepLink(deepLinkPath)
assertEquals(TopLevelDestination.Settings.route, multiBackstack.currentTabRoute)
assertEquals(2, multiBackstack.activeBackStack.size)
assertEquals(SettingsRoutes.About, multiBackstack.activeBackStack.last())
}
}
+4
View File
@@ -54,8 +54,12 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
implementation(libs.jetbrains.navigationevent.compose) implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
} }
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } } val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
@@ -1,113 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalFocusManager
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun <T> AdaptiveListDetailScaffold(
navigator: ThreePaneScaffoldNavigator<T>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onBackToGraph: () -> Unit,
onTabPressedEvent: (ScrollToTopEvent) -> Boolean,
initialKey: T? = null,
listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit,
detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit,
emptyDetailPane: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
val handleBack: () -> Unit = {
if (navigator.canNavigateBack(backNavigationBehavior)) {
scope.launch { navigator.navigateBack(backNavigationBehavior) }
} else {
onBackToGraph()
}
}
val navState = rememberNavigationEventState(NavigationEventInfo.None)
NavigationBackHandler(
state = navState,
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
onBackCancelled = {},
onBackCompleted = { handleBack() },
)
LaunchedEffect(initialKey) {
if (initialKey != null) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey)
}
}
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents.collect { event ->
if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) {
if (navigator.canNavigateBack(backNavigationBehavior)) {
navigator.navigateBack(backNavigationBehavior)
} else {
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
}
}
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
val focusManager = LocalFocusManager.current
// Prevent TextFields from auto-focusing when pane animates in
LaunchedEffect(Unit) { focusManager.clearFocus() }
listPane(
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List,
navigator.currentDestination?.contentKey,
)
}
},
detailPane = {
AnimatedPane {
val focusManager = LocalFocusManager.current
navigator.currentDestination?.contentKey?.let { contentKey ->
key(contentKey) {
LaunchedEffect(contentKey) { focusManager.clearFocus() }
detailPane(contentKey, handleBack)
}
} ?: emptyDetailPane()
}
},
)
}
@@ -16,13 +16,10 @@
*/ */
package org.meshtastic.core.ui.component package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import org.meshtastic.core.navigation.MultiBackstack
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.NodeDetailRoutes import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.core.ui.viewmodel.UIViewModel
@@ -34,22 +31,21 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
*/ */
@Composable @Composable
fun MeshtasticAppShell( fun MeshtasticAppShell(
backStack: NavBackStack<NavKey>, multiBackstack: MultiBackstack,
uiViewModel: UIViewModel, uiViewModel: UIViewModel,
hostModifier: Modifier = Modifier.padding(bottom = 16.dp), hostModifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
LaunchedEffect(uiViewModel) { LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { navKeys -> uiViewModel.navigationDeepLink.collect { navKeys -> multiBackstack.handleDeepLink(navKeys) }
backStack.clear()
backStack.addAll(navKeys)
}
} }
MeshtasticCommonAppSetup( MeshtasticCommonAppSetup(
uiViewModel = uiViewModel, uiViewModel = uiViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid -> onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid)) multiBackstack.activeBackStack.add(
NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid),
)
}, },
) )
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.VerticalDragHandle
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.DialogSceneStrategy
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SinglePaneSceneStrategy
import androidx.navigation3.ui.NavDisplay
import org.meshtastic.core.navigation.MultiBackstack
/** Duration in milliseconds for the shared crossfade transition between navigation scenes. */
private const val TRANSITION_DURATION_MS = 350
/**
* Shared [NavDisplay] wrapper that configures the standard Meshtastic entry decorators, scene strategies, and
* transition animations for all platform hosts.
*
* This version supports multiple backstacks by accepting a [MultiBackstack] state holder.
*/
@Composable
fun MeshtasticNavDisplay(
multiBackstack: MultiBackstack,
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
modifier: Modifier = Modifier,
) {
val backStack = multiBackstack.activeBackStack
MeshtasticNavDisplay(
backStack = backStack,
onBack = { multiBackstack.goBack() },
entryProvider = entryProvider,
modifier = modifier,
)
}
/** Shared [NavDisplay] wrapper for a single backstack. */
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun MeshtasticNavDisplay(
backStack: NavBackStack<NavKey>,
onBack: (() -> Unit)? = null,
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
modifier: Modifier = Modifier,
) {
val listDetailSceneStrategy =
rememberListDetailSceneStrategy<NavKey>(
paneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle = { state ->
val interactionSource = remember { MutableInteractionSource() }
VerticalDragHandle(
modifier =
Modifier.paneExpansionDraggable(
state = state,
minTouchTargetSize = 48.dp,
interactionSource = interactionSource,
),
interactionSource = interactionSource,
)
},
)
val supportingPaneSceneStrategy =
rememberSupportingPaneSceneStrategy<NavKey>(
paneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle = { state ->
val interactionSource = remember { MutableInteractionSource() }
VerticalDragHandle(
modifier =
Modifier.paneExpansionDraggable(
state = state,
minTouchTargetSize = 48.dp,
interactionSource = interactionSource,
),
interactionSource = interactionSource,
)
},
)
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<NavKey>()
val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator<NavKey>()
val activeDecorators =
remember(backStack, saveableDecorator, vmStoreDecorator) { listOf(saveableDecorator, vmStoreDecorator) }
NavDisplay(
backStack = backStack,
entryProvider = entryProvider,
entryDecorators = activeDecorators,
onBack =
onBack
?: {
if (backStack.size > 1) {
backStack.removeLastOrNull()
}
},
sceneStrategies =
listOf(
DialogSceneStrategy(),
listDetailSceneStrategy,
supportingPaneSceneStrategy,
SinglePaneSceneStrategy(),
),
transitionSpec = meshtasticTransitionSpec(),
popTransitionSpec = meshtasticTransitionSpec(),
modifier = modifier,
)
}
/** Shared crossfade [ContentTransform] used for both forward and pop navigation. */
private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
ContentTransform(
fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)),
fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)),
)
}
@@ -22,27 +22,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,16 +46,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.window.core.layout.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting import org.meshtastic.core.resources.connecting
@@ -70,13 +62,15 @@ import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.core.ui.viewmodel.UIViewModel
/** /**
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and * Shared adaptive navigation shell using [NavigationSuiteScaffold].
* desktop targets. *
* This implementation uses the [MultiBackstack] state holder to manage independent histories for each tab, aligning
* with Navigation 3 best practices for state preservation during tab switching.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MeshtasticNavigationSuite( fun MeshtasticNavigationSuite(
backStack: NavBackStack<NavKey>, multiBackstack: MultiBackstack,
uiViewModel: UIViewModel, uiViewModel: UIViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
@@ -86,60 +80,69 @@ fun MeshtasticNavigationSuite(
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle() val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true) val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val currentKey = backStack.lastOrNull()
val rootKey = backStack.firstOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
val onNavigate = { destination: TopLevelDestination -> val currentTabRoute = multiBackstack.currentTabRoute
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel) val topLevelDestination = TopLevelDestination.fromNavKey(currentTabRoute)
}
if (isCompact) { val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType()
Scaffold( val showLabels = layoutType == NavigationSuiteType.NavigationRail
modifier = modifier,
bottomBar = { NavigationSuiteScaffold(
MeshtasticNavigationBar( modifier = modifier,
topLevelDestination = topLevelDestination, layoutType = layoutType,
connectionState = connectionState, navigationSuiteItems = {
unreadMessageCount = unreadMessageCount, TopLevelDestination.entries.forEach { destination ->
selectedDevice = selectedDevice, val isSelected = destination == topLevelDestination
uiViewModel = uiViewModel, item(
onNavigate = onNavigate, selected = isSelected,
onClick = { handleNavigation(destination, topLevelDestination, multiBackstack, uiViewModel) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = isSelected,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label =
if (showLabels) {
{ Text(stringResource(destination.label)) }
} else {
null
},
) )
}, }
) { padding -> },
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() } ) {
} Row { content() }
} else {
Row(modifier = modifier.fillMaxSize()) {
MeshtasticNavigationRail(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
)
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
}
} }
} }
/**
* Caps [NavigationSuiteType] so that expanded/extra-large widths still use a NavigationRail instead of promoting to a
* permanent NavigationDrawer.
*/
private fun NavigationSuiteType.coerceNavigationType(): NavigationSuiteType = when (this) {
NavigationSuiteType.NavigationDrawer -> NavigationSuiteType.NavigationRail
else -> this
}
private fun handleNavigation( private fun handleNavigation(
destination: TopLevelDestination, destination: TopLevelDestination,
topLevelDestination: TopLevelDestination?, topLevelDestination: TopLevelDestination?,
currentKey: NavKey?, multiBackstack: MultiBackstack,
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel, uiViewModel: UIViewModel,
) { ) {
val isRepress = destination == topLevelDestination val isRepress = destination == topLevelDestination
if (isRepress) { if (isRepress) {
val currentKey = multiBackstack.activeBackStack.lastOrNull()
when (destination) { when (destination) {
TopLevelDestination.Nodes -> { TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
if (!onNodesList) { if (!onNodesList) {
backStack.navigateTopLevel(destination.route) multiBackstack.navigateTopLevel(destination.route)
} else { } else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed) uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
} }
@@ -148,78 +151,19 @@ private fun handleNavigation(
val onConversationsList = val onConversationsList =
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
if (!onConversationsList) { if (!onConversationsList) {
backStack.navigateTopLevel(destination.route) multiBackstack.navigateTopLevel(destination.route)
} else { } else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed) uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
} }
} }
else -> { else -> {
if (currentKey != destination.route) { if (currentKey != destination.route) {
backStack.navigateTopLevel(destination.route) multiBackstack.navigateTopLevel(destination.route)
} }
} }
} }
} else { } else {
backStack.navigateTopLevel(destination.route) multiBackstack.navigateTopLevel(destination.route)
}
}
@Composable
private fun MeshtasticNavigationBar(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationBar {
TopLevelDestination.entries.forEach { destination ->
NavigationBarItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
)
}
}
}
@Composable
private fun MeshtasticNavigationRail(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label = { Text(stringResource(destination.label)) },
)
}
} }
} }
+2 -1
View File
@@ -155,6 +155,7 @@ dependencies {
implementation(projects.core.di) implementation(projects.core.di)
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.navigation) implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.repository) implementation(projects.core.repository)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.data) implementation(projects.core.data)
@@ -192,8 +193,8 @@ dependencies {
// Navigation 3 (JetBrains fork — multiplatform) // Navigation 3 (JetBrains fork — multiplatform)
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(libs.jetbrains.lifecycle.runtime.compose)
// Koin DI // Koin DI
@@ -36,20 +36,16 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
@@ -63,10 +59,9 @@ import okio.Path.Companion.toPath
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel import org.meshtastic.core.navigation.rememberMultiBackstack
import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.AppTheme
@@ -78,32 +73,12 @@ import org.meshtastic.desktop.ui.DesktopMainScreen
import java.awt.Desktop import java.awt.Desktop
import java.util.Locale import java.util.Locale
/** /** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */
* Meshtastic Desktop the first non-Android target for the shared KMP module graph.
*
* Launches a Compose Desktop window with a Navigation 3 shell that mirrors the Android app's navigation architecture:
* shared routes from `core:navigation`, a `NavigationRail` for top-level destinations, and `NavDisplay` for rendering
* the current backstack entry.
*/
/**
* Static CompositionLocal used as a recomposition trigger for locale changes. When the value changes,
* [staticCompositionLocalOf] forces the **entire subtree** under the provider to recompose unlike [key] which
* destroys and recreates state (including the navigation backstack). During recomposition, CMP Resources'
* `rememberResourceEnvironment` re-reads `Locale.current` (which wraps `java.util.Locale.getDefault()`) and picks up
* the new locale, causing all `stringResource()` calls to resolve in the updated language.
*/
private val LocalAppLocale = staticCompositionLocalOf { "" } private val LocalAppLocale = staticCompositionLocalOf { "" }
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB
/**
* Loads a [Painter] from a Java classpath resource path (e.g. `"icon.png"`).
*
* This replaces the deprecated `androidx.compose.ui.res.painterResource(String)` API. Desktop native-distribution icons
* (`.icns`, `.ico`) remain in `src/main/resources` for the packaging plugin; this helper reads the same directory at
* runtime.
*/
@Composable @Composable
private fun classpathPainterResource(path: String): Painter { private fun classpathPainterResource(path: String): Painter {
val bitmap: ImageBitmap = val bitmap: ImageBitmap =
@@ -145,7 +120,6 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
} }
} }
// Start the mesh service processing chain (desktop equivalent of Android's MeshService)
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() } val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
DisposableEffect(Unit) { DisposableEffect(Unit) {
meshServiceController.start() meshServiceController.start()
@@ -153,18 +127,15 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
} }
val uiPrefs = remember { koinApp.koin.get<UiPrefs>() } val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
val themePref by uiPrefs.theme.collectAsState(initial = -1) // -1 is SYSTEM usually val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "") val localePref by uiPrefs.locale.collectAsState(initial = "")
// Apply persisted locale to the JVM default synchronously so CMP Resources sees
// it during the current composition frame. Empty string falls back to the startup
// system locale captured before any app-specific override was applied.
Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale)
val isDarkTheme = val isDarkTheme =
when (themePref) { when (themePref) {
1 -> false // MODE_NIGHT_NO 1 -> false
2 -> true // MODE_NIGHT_YES 2 -> true
else -> isSystemInDarkTheme() else -> isSystemInDarkTheme()
} }
@@ -184,10 +155,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
val windowState = rememberWindowState() val windowState = rememberWindowState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
trayState.sendNotification(notification)
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -223,25 +191,13 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
onAction = { isAppVisible = true }, onAction = { isAppVisible = true },
menu = { menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true }) Item("Show Meshtastic", onClick = { isAppVisible = true })
Item(
"Test Notification",
onClick = {
trayState.sendNotification(
Notification(
"Meshtastic",
"This is a test notification from the System Tray",
Notification.Type.Info,
),
)
},
)
Item("Quit", onClick = ::exitApplication) Item("Quit", onClick = ::exitApplication)
}, },
) )
if (isWindowReady && isAppVisible) { if (isWindowReady && isAppVisible) {
val backStack = val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey) val backStack = multiBackstack.activeBackStack
Window( Window(
onCloseRequest = { isAppVisible = false }, onCloseRequest = { isAppVisible = false },
@@ -251,46 +207,34 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
onPreviewKeyEvent = { event -> onPreviewKeyEvent = { event ->
if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
when { when {
// ⌘Q → Quit
event.key == Key.Q -> { event.key == Key.Q -> {
exitApplication() exitApplication()
true true
} }
// ⌘, → Settings
event.key == Key.Comma -> { event.key == Key.Comma -> {
if ( if (
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
) { ) {
backStack.navigateTopLevel(TopLevelDestination.Settings.route) multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
} }
true true
} }
// ⌘⇧T → Toggle theme
event.key == Key.T && event.isShiftPressed -> {
uiPrefs.setTheme(if (isDarkTheme) 1 else 2)
true
}
// ⌘1 → Conversations
event.key == Key.One -> { event.key == Key.One -> {
backStack.navigateTopLevel(TopLevelDestination.Conversations.route) multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
true true
} }
// ⌘2 → Nodes
event.key == Key.Two -> { event.key == Key.Two -> {
backStack.navigateTopLevel(TopLevelDestination.Nodes.route) multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
true true
} }
// ⌘3 → Map
event.key == Key.Three -> { event.key == Key.Three -> {
backStack.navigateTopLevel(TopLevelDestination.Map.route) multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
true true
} }
// ⌘4 → Connections
event.key == Key.Four -> { event.key == Key.Four -> {
backStack.navigateTopLevel(TopLevelDestination.Connections.route) multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
true true
} }
// ⌘/ → About
event.key == Key.Slash -> { event.key == Key.Slash -> {
backStack.add(SettingsRoutes.About) backStack.add(SettingsRoutes.About)
true true
@@ -299,14 +243,12 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
} }
}, },
) { ) {
// Configure Coil ImageLoader for desktop with SVG decoding and network fetching.
// This is the desktop equivalent of the Android app's NetworkModule.provideImageLoader().
setSingletonImageLoaderFactory { context -> setSingletonImageLoaderFactory { context ->
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache" val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3"
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
add(KtorNetworkFetcherFactory()) add(KtorNetworkFetcherFactory())
add(SvgDecoder.Factory()) add(SvgDecoder.Factory(renderToBitmap = false))
} }
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
.diskCache { .diskCache {
@@ -316,12 +258,8 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
.build() .build()
} }
// Providing localePref via a staticCompositionLocalOf forces the entire subtree to
// recompose when the locale changes — CMP Resources' rememberResourceEnvironment then
// re-reads Locale.current and all stringResource() calls update. Unlike key(), this
// preserves remembered state (including the navigation backstack).
CompositionLocalProvider(LocalAppLocale provides localePref) { CompositionLocalProvider(LocalAppLocale provides localePref) {
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(backStack) } AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
} }
} }
} }
@@ -18,45 +18,38 @@ package org.meshtastic.desktop.ui
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay import org.meshtastic.core.navigation.MultiBackstack
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.ui.component.MeshtasticAppShell import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph import org.meshtastic.desktop.navigation.desktopNavGraph
/** /** Desktop main screen — uses shared navigation components. */
* Desktop main screen Navigation 3 shell with a persistent [NavigationRail] and [NavDisplay].
*
* Uses the same shared routes from `core:navigation` and the same `NavDisplay` + `entryProvider` pattern as the Android
* app, proving the shared backstack architecture works across targets.
*/
@Composable @Composable
@Suppress("LongMethod") fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) {
fun DesktopMainScreen(backStack: NavBackStack<NavKey>, uiViewModel: UIViewModel = koinViewModel()) { val backStack = multiBackstack.activeBackStack
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Surface(modifier = Modifier.fillMaxSize()) {
MeshtasticAppShell( MeshtasticAppShell(
backStack = backStack, multiBackstack = multiBackstack,
uiViewModel = uiViewModel, uiViewModel = uiViewModel,
hostModifier = Modifier.padding(bottom = 24.dp), hostModifier = Modifier.padding(bottom = 24.dp),
) { ) {
org.meshtastic.core.ui.component.MeshtasticNavigationSuite( MeshtasticNavigationSuite(
backStack = backStack, multiBackstack = multiBackstack,
uiViewModel = uiViewModel, uiViewModel = uiViewModel,
modifier = Modifier.fillMaxSize(),
) { ) {
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) } val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
MeshtasticNavDisplay(
NavDisplay( multiBackstack = multiBackstack,
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = provider, entryProvider = provider,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -37,6 +37,8 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures. - Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
- Don't parse deep links manually in platform code or push single routes without a backstack. - Don't parse deep links manually in platform code or push single routes without a backstack.
- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths. - Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths.
- **Don't use a single `NavBackStack` list for multiple tabs, nor reuse the same `NavEntryDecorator` instances across different backstacks.**
- **Do** use `MultiBackstack` (from `core:navigation`) to manage independent `NavBackStack` instances per tab. When rendering the active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance. Failure to swap decorators when swapping backstacks causes Navigation 3 to perceive the inactive entries as "popped", permanently destroying their `ViewModelStore` and saved UI state.
### Current code anchors (Navigation 3) ### Current code anchors (Navigation 3)
@@ -0,0 +1,128 @@
<!--
- 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.
-->
# Navigation 3 & Material 3 Adaptive — API Alignment Audit
**Date:** 2026-03-26
**Status:** Active
**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration.
**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated).
## Current Dependency Baseline
| Library | Version | Group |
|---|---|---|
| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` |
| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` |
| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` |
| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` |
| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` |
| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` |
| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` |
## API Audit: What's Available vs. What We Use
### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`)
**Available APIs we're NOT using:**
| API | Purpose | Status in project |
|---|---|---|
| `sceneStrategies: List<SceneStrategy<T>>` | Allows NavDisplay to render multi-pane Scenes | ❌ Not used — defaulting to `SinglePaneSceneStrategy` |
| `SceneStrategy<T>` interface | Custom scene calculation from backstack entries | ❌ Not used |
| `DialogSceneStrategy` | Renders `entry<T>(metadata = dialog())` entries as overlay Dialogs | ❌ Not used — dialogs handled manually |
| `SceneDecoratorStrategy<T>` | Wraps/decorates scenes with additional UI | ❌ Not used |
| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ❌ Not used |
| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used |
| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ❌ Not used — no transitions at all |
| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used |
| `entryDecorators: List<NavEntryDecorator<T>>` | Wraps entry content with additional behavior | ❌ Not used (defaulting to `SaveableStateHolderNavEntryDecorator`) |
**APIs we ARE using correctly:**
| API | Usage |
|---|---|
| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` |
| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence |
| `entryProvider<NavKey> { entry<T> { ... } }` | All feature graph registrations |
| `NavigationBackHandler` from `navigationevent-compose` | Used in `AdaptiveListDetailScaffold` |
### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`)
**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project declares this dependency in `desktop/build.gradle.kts` but does **not** pass it as an `entryDecorator` to `NavDisplay`.
Currently, `koinViewModel()` calls inside `entry<T>` blocks use the nearest `ViewModelStoreOwner` from the composition — which is the Activity/Window level. This means:
- ViewModels are **not** automatically cleared when their entry is popped from the backstack.
- The project works around this with manual `key = "metrics-$destNum"` parameter keying.
**Opportunity:** Adding `rememberViewModelStoreNavEntryDecorator()` to `NavDisplay.entryDecorators` would give each backstack entry its own `ViewModelStoreOwner`, so `koinViewModel()` calls would be automatically scoped to the entry's lifetime.
### 3. Material 3 Adaptive — Nav3 Scene Integration
**Key finding:** The JetBrains `adaptive-navigation` artifact at `1.3.0-alpha06` does **NOT** include `MaterialListDetailSceneStrategy`. That API only exists in the Google AndroidX version (`androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha09+`).
This means the project **cannot** currently use the official M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. The current approach of hosting `ListDetailPaneScaffold` inside `entry<T>` blocks (via `AdaptiveListDetailScaffold`) is the correct pattern for the JetBrains fork at this version.
**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set.
### 4. NavigationSuiteScaffold (`1.11.0-alpha05`)
**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed.
## Prioritized Opportunities
### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk)
**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`.
**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time.
### P1: Add default NavDisplay transitions (medium-value, low-risk)
**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition.
**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions.
### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk)
**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry<T>(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support.
**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected.
### Consolidation: `MeshtasticNavDisplay` shared wrapper
**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration:
- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator`
- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy`
- Transition specs: 350 ms crossfade (forward + pop)
Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`.
### P3: Per-entry transition metadata (low-value until Scene adoption)
Individual entries can declare custom transitions via `entry<T>(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade).
**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption.
### Deferred: Scene-based multi-pane layout
The `MaterialListDetailSceneStrategy` is not available in the JetBrains adaptive fork at `1.3.0-alpha06`. The project's `AdaptiveListDetailScaffold` wrapper is the correct approach for now. Revisit when the JetBrains fork includes the Scene bridge, or consider writing a custom `SceneStrategy` that integrates with the existing `ListDetailPaneScaffold`.
## Decision
~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~
**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred.
## References
- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache)
- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream)
- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream)
- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06`
+19 -10
View File
@@ -36,19 +36,28 @@ Both modules still define separate graph-builder files (`app/navigation/*.kt`, `
4. **Predictive back handling is KMP native.** 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`. - 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) ## Alpha04 → Beta01 Changelog Impact Check
Source reviewed: Compose Multiplatform `v1.11.0-alpha04` release notes. Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta01`, Lifecycle `2.11.0-alpha02`.
1. **No direct Navigation 3 API breakage called out.** > **Superseded by:** [`navigation3-api-alignment-2026-03.md`](navigation3-api-alignment-2026-03.md) for the full API surface audit and Scene architecture adoption plan.
- Release notes include component version bumps for Navigation 3 (`1.1.0-alpha04`) but no `NavBackStack`, `NavDisplay`, or `entryProvider` API migration requirements.
- Existing shell patterns in `app` and `desktop` remain valid. 1. **NavDisplay API updated to Scene-based architecture.**
2. **Primary risk is dependency wiring drift, not runtime behavior.** - The `sceneStrategy: SceneStrategy<T>` parameter is deprecated in favor of `sceneStrategies: List<SceneStrategy<T>>`.
- New `sceneDecoratorStrategies: List<SceneDecoratorStrategy<T>>` parameter available.
- New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions.
- Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`.
2. **Entry-scoped ViewModel lifecycle adopted.**
- Both `app` and `desktop` now pass `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` as explicit `entryDecorators` to `NavDisplay`.
- ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are now scoped to the entry's backstack lifetime.
3. **No direct Navigation 3 API breakage.**
- Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns.
4. **Primary risk is dependency wiring drift, not runtime behavior.**
- JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog. - JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog.
3. **Saved-state and typed-route parity risk remains unchanged.** - Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`).
- Desktop still uses manual serializer registration; this is an existing risk and not introduced by alpha04. 5. **Saved-state and typed-route parity risk remains unchanged.**
4. **Compose-wide migration notes do not currently impact navigation codepaths.** - Desktop still uses manual serializer registration; this is an existing risk and not introduced by beta01.
- `Shader` wrapper changes and `Canvas.nativeCanvas` deprecations are not used in the Navigation 3 shell files. 6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).**
### Actions Taken ### Actions Taken
+9 -4
View File
@@ -155,14 +155,19 @@ Remaining to be extracted from `:app` or unified in `commonMain`:
| Dependency | Version | Why | | Dependency | Version | Why |
|---|---|---| |---|---|---|
| Compose Multiplatform | `1.11.0-alpha04` | Required for JetBrains Adaptive `1.3.0-alpha06` | | Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` |
| Koin | `4.2.0-RC2` | Nav3 + K2 compiler plugin support | | Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` |
| JetBrains Lifecycle | `2.10.0-beta01` | Multiplatform ViewModel/lifecycle | | Koin | `4.2.0` | Nav3 + K2 compiler plugin support |
| JetBrains Navigation 3 | `1.1.0-alpha04` | Multiplatform navigation | | JetBrains Lifecycle | `2.11.0-alpha02` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels |
| JetBrains Navigation 3 | `1.1.0-beta01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs |
| JetBrains Navigation Event | `1.1.0-alpha01` | KMP `NavigationBackHandler` for predictive back |
| JetBrains Material 3 Adaptive | `1.3.0-alpha06` | `ListDetailPaneScaffold`, `ThreePaneScaffold`, Large/XL breakpoints |
| Kable BLE | `0.42.0` | Provides fully multiplatform BLE support | | Kable BLE | `0.42.0` | Provides fully multiplatform BLE support |
**Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features. **Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features.
> See [`decisions/navigation3-api-alignment-2026-03.md`](./decisions/navigation3-api-alignment-2026-03.md) for the full Navigation 3 API surface audit and Scene architecture adoption plan.
## References ## References
- Roadmap: [`docs/roadmap.md`](./roadmap.md) - Roadmap: [`docs/roadmap.md`](./roadmap.md)
@@ -19,13 +19,12 @@ package org.meshtastic.feature.intro
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
/** /**
* Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states. * Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
@@ -58,9 +57,8 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
val backStack = rememberNavBackStack(Welcome) val backStack = rememberNavBackStack(Welcome)
NavDisplay<NavKey>( MeshtasticNavDisplay(
backStack = backStack, backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider =
introNavGraph( introNavGraph(
backStack = backStack, backStack = backStack,
+1
View File
@@ -51,6 +51,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
} }
androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
@@ -16,6 +16,8 @@
*/ */
package org.meshtastic.feature.messaging.navigation package org.meshtastic.feature.messaging.navigation
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.replaceLast
import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.QuickChatScreen import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.QuickChatViewModel import org.meshtastic.feature.messaging.QuickChatViewModel
@@ -33,55 +36,54 @@ import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod") @Suppress("LongMethod")
fun EntryProviderScope<NavKey>.contactsGraph( fun EntryProviderScope<NavKey>.contactsGraph(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(), scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
) { ) {
entry<ContactsRoutes.ContactsGraph> { entry<ContactsRoutes.ContactsGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
} }
entry<ContactsRoutes.Contacts> { entry<ContactsRoutes.Contacts>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
} }
entry<ContactsRoutes.Messages> { args -> entry<ContactsRoutes.Messages>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
ContactsEntryContent( val contactKey = args.contactKey
backStack = backStack, val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel =
scrollToTopEvents = scrollToTopEvents, koinViewModel(key = "messages-$contactKey")
initialContactKey = args.contactKey, messageViewModel.setContactKey(contactKey)
initialMessage = args.message,
org.meshtastic.feature.messaging.MessageScreen(
contactKey = contactKey,
message = args.message,
viewModel = messageViewModel,
navigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
onNavigateBack = { backStack.removeLastOrNull() },
) )
} }
entry<ContactsRoutes.Share> { args -> entry<ContactsRoutes.Share>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val message = args.message val message = args.message
val viewModel = koinViewModel<ContactsViewModel>() val viewModel = koinViewModel<ContactsViewModel>()
ShareScreen( ShareScreen(
viewModel = viewModel, viewModel = viewModel,
onConfirm = { onConfirm = { contactKey -> backStack.replaceLast(ContactsRoutes.Messages(contactKey, message)) },
// Navigation 3 - replace Top with Messages manually, but for now we just pop and add
backStack.removeLastOrNull()
backStack.add(ContactsRoutes.Messages(it, message))
},
onNavigateUp = { backStack.removeLastOrNull() }, onNavigateUp = { backStack.removeLastOrNull() },
) )
} }
entry<ContactsRoutes.QuickChat> { entry<ContactsRoutes.QuickChat>(metadata = { ListDetailSceneStrategy.extraPane() }) {
val viewModel = koinViewModel<QuickChatViewModel>() val viewModel = koinViewModel<QuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
} }
} }
@Composable @Composable
fun ContactsEntryContent( fun ContactsEntryContent(backStack: NavBackStack<NavKey>, scrollToTopEvents: Flow<ScrollToTopEvent>) {
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel() val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
@@ -90,30 +92,11 @@ fun ContactsEntryContent(
AdaptiveContactsScreen( AdaptiveContactsScreen(
backStack = backStack, backStack = backStack,
contactsViewModel = contactsViewModel, contactsViewModel = contactsViewModel,
messageViewModel = koinViewModel(), // Ignored by custom detail pane below
scrollToTopEvents = scrollToTopEvents, scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested, sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet, requestChannelSet = requestChannelSet,
onHandleDeepLink = uiViewModel::handleDeepLink, onHandleDeepLink = uiViewModel::handleDeepLink,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested, onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl, onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,
initialMessage = initialMessage,
detailPaneCustom = { contactKey ->
val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel =
koinViewModel(key = "messages-$contactKey")
messageViewModel.setContactKey(contactKey)
org.meshtastic.feature.messaging.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() },
)
},
) )
} }
@@ -16,118 +16,41 @@
*/ */
package org.meshtastic.feature.messaging.ui.contact package org.meshtastic.feature.messaging.ui.contact
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.navigation.ChannelsRoutes import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.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.ChannelSet
import org.meshtastic.proto.SharedContact import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun AdaptiveContactsScreen( fun AdaptiveContactsScreen(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
contactsViewModel: ContactsViewModel, contactsViewModel: ContactsViewModel,
messageViewModel: MessageViewModel,
scrollToTopEvents: Flow<ScrollToTopEvent>, scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?, sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?, requestChannelSet: ChannelSet?,
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit, onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit, onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
initialMessage: String = "",
detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null,
) { ) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>() ContactsScreen(
val scope = rememberCoroutineScope() onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
val onBackToGraph: () -> Unit = { requestChannelSet = requestChannelSet,
val currentKey = backStack.lastOrNull() onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
if ( onClearRequestChannelUrl = onClearRequestChannelUrl,
currentKey is ContactsRoutes.Messages || viewModel = contactsViewModel,
currentKey is ContactsRoutes.Contacts || onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
currentKey is ContactsRoutes.ContactsGraph onNavigateToMessages = { contactKey -> backStack.add(ContactsRoutes.Messages(contactKey)) },
) { onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
// Check if we navigated here from another screen (e.g., from Nodes or Map)
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
val isFromDifferentGraph =
previousKey != null &&
previousKey !is ContactsRoutes.ContactsGraph &&
previousKey !is ContactsRoutes.Contacts &&
previousKey !is ContactsRoutes.Messages
if (isFromDifferentGraph) {
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
backStack.removeLastOrNull()
}
}
}
AdaptiveListDetailScaffold(
navigator = navigator,
scrollToTopEvents = scrollToTopEvents, scrollToTopEvents = scrollToTopEvents,
onBackToGraph = onBackToGraph, activeContactKey = null,
onTabPressedEvent = { it is ScrollToTopEvent.ConversationsTabPressed },
initialKey = initialContactKey,
listPane = { isActive, activeContactKey ->
ContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToMessages = { contactKey ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
},
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
activeContactKey = activeContactKey,
)
},
detailPane = { contentKey, handleBack ->
if (detailPaneCustom != null) {
detailPaneCustom(contentKey)
} else {
MessageScreen(
contactKey = contentKey,
message = if (contentKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,
)
}
},
emptyDetailPane = {
EmptyDetailPlaceholder(
icon = MeshtasticIcons.Conversations,
title = stringResource(Res.string.conversations),
)
},
) )
} }
+1
View File
@@ -59,6 +59,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
} }
androidMain.dependencies { androidMain.dependencies {
@@ -16,93 +16,31 @@
*/ */
package org.meshtastic.feature.node.navigation package org.meshtastic.feature.node.navigation
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.list.NodeListScreen import org.meshtastic.feature.node.list.NodeListScreen
import org.meshtastic.feature.node.list.NodeListViewModel import org.meshtastic.feature.node.list.NodeListViewModel
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun AdaptiveNodeListScreen( fun AdaptiveNodeListScreen(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>, scrollToTopEvents: Flow<ScrollToTopEvent>,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) { ) {
val nodeListViewModel: NodeListViewModel = koinViewModel() val nodeListViewModel: NodeListViewModel = koinViewModel()
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
val onBackToGraph: () -> Unit = { NodeListScreen(
val currentKey = backStack.lastOrNull() viewModel = nodeListViewModel,
val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph navigateToNodeDetails = { nodeId -> backStack.add(NodesRoutes.NodeDetail(nodeId)) },
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
val isFromDifferentGraph =
previousKey != null && previousKey !is NodesRoutes.NodesGraph && previousKey !is NodesRoutes.Nodes
if (isFromDifferentGraph && !isNodesRoute) {
// Navigate back via NavController to return to the previous screen
backStack.removeLastOrNull()
}
}
AdaptiveListDetailScaffold(
navigator = navigator,
scrollToTopEvents = scrollToTopEvents, scrollToTopEvents = scrollToTopEvents,
onBackToGraph = onBackToGraph, activeNodeId = null,
onTabPressedEvent = { it is ScrollToTopEvent.NodesTabPressed }, onHandleDeepLink = onHandleDeepLink,
initialKey = initialNodeId,
listPane = { isActive, activeNodeId ->
NodeListScreen(
viewModel = nodeListViewModel,
navigateToNodeDetails = { nodeId ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
},
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = activeNodeId,
onHandleDeepLink = onHandleDeepLink,
)
},
detailPane = { contentKey, handleBack ->
val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
val compassViewModel: CompassViewModel = koinViewModel()
NodeDetailScreen(
nodeId = contentKey,
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = onNavigateToMessages,
onNavigate = onNavigate,
onNavigateUp = handleBack,
)
},
emptyDetailPane = {
EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
},
) )
} }
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PermScanWifi import androidx.compose.material.icons.rounded.PermScanWifi
import androidx.compose.material.icons.rounded.Power import androidx.compose.material.icons.rounded.Power
import androidx.compose.material.icons.rounded.Router import androidx.compose.material.icons.rounded.Router
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
@@ -51,6 +53,9 @@ import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
@@ -63,28 +68,25 @@ import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import kotlin.reflect.KClass import kotlin.reflect.KClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod") @Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodesGraph( fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(), scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) { ) {
entry<NodesRoutes.NodesGraph> { entry<NodesRoutes.NodesGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
AdaptiveNodeListScreen( AdaptiveNodeListScreen(
backStack = backStack, backStack = backStack,
scrollToTopEvents = scrollToTopEvents, scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink, onHandleDeepLink = onHandleDeepLink,
) )
} }
entry<NodesRoutes.Nodes> { entry<NodesRoutes.Nodes>(metadata = { ListDetailSceneStrategy.listPane() }) {
AdaptiveNodeListScreen( AdaptiveNodeListScreen(
backStack = backStack, backStack = backStack,
scrollToTopEvents = scrollToTopEvents, scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink, onHandleDeepLink = onHandleDeepLink,
) )
} }
@@ -92,42 +94,42 @@ fun EntryProviderScope<NavKey>.nodesGraph(
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink) nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink)
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod") @Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodeDetailGraph( fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>, scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) { ) {
entry<NodesRoutes.NodeDetailGraph> { args -> entry<NodesRoutes.NodeDetailGraph>(metadata = { ListDetailSceneStrategy.listPane() }) { args ->
AdaptiveNodeListScreen( AdaptiveNodeListScreen(
backStack = backStack, backStack = backStack,
scrollToTopEvents = scrollToTopEvents, scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink, onHandleDeepLink = onHandleDeepLink,
) )
} }
entry<NodesRoutes.NodeDetail> { args -> entry<NodesRoutes.NodeDetail>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
AdaptiveNodeListScreen( val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
backStack = backStack, val compassViewModel: CompassViewModel = koinViewModel()
scrollToTopEvents = scrollToTopEvents, val destNum = args.destNum ?: 0 // Handle nullable destNum if needed
initialNodeId = args.destNum, NodeDetailScreen(
nodeId = destNum,
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onNavigate = { backStack.add(it) }, onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, onNavigateUp = { backStack.removeLastOrNull() },
onHandleDeepLink = onHandleDeepLink,
) )
} }
entry<NodeDetailRoutes.NodeMap> { args -> entry<NodeDetailRoutes.NodeMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
mapScreen(args.destNum) { backStack.removeLastOrNull() } mapScreen(args.destNum) { backStack.removeLastOrNull() }
} }
entry<NodeDetailRoutes.TracerouteLog> { args -> entry<NodeDetailRoutes.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val metricsViewModel = val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum) metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen( TracerouteLogScreen(
@@ -145,7 +147,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
) )
} }
entry<NodeDetailRoutes.TracerouteMap> { args -> entry<NodeDetailRoutes.TracerouteMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() } tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
} }
@@ -175,14 +177,15 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass } fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass }
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable( private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
routeInfo: NodeDetailRoute, routeInfo: NodeDetailRoute,
crossinline getDestNum: (R) -> Int, crossinline getDestNum: (R) -> Int,
) { ) {
entry<R> { args -> entry<R>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val destNum = getDestNum(args) val destNum = getDestNum(args)
val metricsViewModel = koinViewModel<MetricsViewModel>(key = "metrics-$destNum") { parametersOf(destNum) } val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum) metricsViewModel.setNodeId(destNum)
routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() } routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }
+2
View File
@@ -140,6 +140,8 @@ compose-multiplatform-materialIconsExtended = { module = "org.jetbrains.compose.
jetbrains-compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jetbrains-adaptive" } jetbrains-compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jetbrains-adaptive" }
jetbrains-compose-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "jetbrains-adaptive" } jetbrains-compose-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "jetbrains-adaptive" }
jetbrains-compose-material3-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "jetbrains-adaptive" } jetbrains-compose-material3-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "jetbrains-adaptive" }
jetbrains-compose-material3-adaptive-navigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "jetbrains-adaptive" }
jetbrains-compose-material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "compose-multiplatform-material3" }
# Google # Google
firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" }