diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8dea1e55c..07ee9eae8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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:di` | Common DI qualifiers and dispatchers. | | `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:api` | Public AIDL/API integration module for external clients. | | `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`. - **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`. -- **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` 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. - **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. diff --git a/AGENTS.md b/AGENTS.md index 791689062..547db517a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: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: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:api` | Public AIDL/API integration module for external clients. | | `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. - **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. - **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. -- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp. +- **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`. ### 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` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack: NavBackStack)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider` block. Do NOT define navigation graphs in platform-specific source sets. - **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`. -- **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` blocks are scoped to the entry's backstack lifetime and cleared on pop. - **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`. - **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. diff --git a/GEMINI.md b/GEMINI.md index 2dede594d..0db75b419 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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:di` | Common DI qualifiers and dispatchers. | | `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:api` | Public AIDL/API integration module for external clients. | | `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`. - **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`. -- **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` blocks are scoped to the entry's backstack lifetime and cleared on pop. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. - **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 752b2be0b..cb0b23f5e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,7 @@ dependencies { implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.navigation) + implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(projects.core.network) implementation(projects.core.nfc) implementation(projects.core.prefs) diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 66f518d3e..8b3e85b9c 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -139,7 +139,7 @@ class MainActivity : ComponentActivity() { ReportDrawnWhen { true } if (appIntroCompleted) { - MainScreen(uIViewModel = model) + MainScreen() } else { val introViewModel = koinViewModel() AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel) @@ -174,7 +174,7 @@ class MainActivity : ComponentActivity() { org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides { destNum, requestId, logUuid, onNavigateUp -> val metricsViewModel = - koinViewModel(key = "metrics-$destNum") { + koinViewModel { org.koin.core.parameter.parametersOf(destNum) } metricsViewModel.setNodeId(destNum) diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 5753d316a..4cad8493c 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -19,30 +19,27 @@ package org.meshtastic.app.ui import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay import co.touchlab.kermit.Logger import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_too_old import org.meshtastic.core.resources.must_update 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.feature.connections.navigation.connectionsGraph 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.radio.channel.channelsGraph -@OptIn(ExperimentalMaterial3Api::class) -@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) { - val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey) +fun MainScreen() { + val viewModel: UIViewModel = koinViewModel() + val multiBackstack = rememberMultiBackstack(NodesRoutes.NodesGraph) + val backStack = multiBackstack.activeBackStack - AndroidAppVersionCheck(uIViewModel) + AndroidAppVersionCheck(viewModel) - MeshtasticAppShell( - backStack = backStack, - uiViewModel = uIViewModel, - hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp), - ) { - org.meshtastic.core.ui.component.MeshtasticNavigationSuite( - backStack = backStack, - uiViewModel = uIViewModel, + MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { + MeshtasticNavigationSuite( + multiBackstack = multiBackstack, + uiViewModel = viewModel, modifier = Modifier.fillMaxSize(), ) { val provider = entryProvider { - contactsGraph(backStack, uIViewModel.scrollToTopEventFlow) + contactsGraph(backStack, viewModel.scrollToTopEventFlow) nodesGraph( backStack = backStack, - scrollToTopEvents = uIViewModel.scrollToTopEventFlow, - onHandleDeepLink = uIViewModel::handleDeepLink, + scrollToTopEvents = viewModel.scrollToTopEventFlow, + onHandleDeepLink = viewModel::handleDeepLink, ) mapGraph(backStack) channelsGraph(backStack) @@ -84,8 +77,8 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) { settingsGraph(backStack) firmwareGraph(backStack) } - NavDisplay( - backStack = backStack, + MeshtasticNavDisplay( + multiBackstack = multiBackstack, entryProvider = provider, modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(), ) @@ -99,7 +92,6 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) { val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() - // Check if the device is running an old app version LaunchedEffect(connectionState, myNodeInfo) { if (connectionState == ConnectionState.Connected) { myNodeInfo?.let { info -> diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt new file mode 100644 index 000000000..067ee2ae7 --- /dev/null +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt @@ -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 . + */ +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> = emptyMap() + + var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab) + private set + + val activeBackStack: NavBackStack + 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) { + 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>() + + TopLevelDestination.entries.forEach { dest -> + key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) } + } + + val multiBackstack = remember { MultiBackstack(initialTab) } + multiBackstack.backStacks = stacks + + return multiBackstack +} diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt index 5638814f8..fa597c65f 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt @@ -19,16 +19,38 @@ package org.meshtastic.core.navigation 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 - * root destination. + * Replaces the last entry in the back stack with the given route. If the back stack is empty, it simply adds the route. */ -fun MutableList.navigateTopLevel(route: NavKey) { +fun MutableList.replaceLast(route: NavKey) { if (isNotEmpty()) { - this[0] = route - while (size > 1) { - removeAt(lastIndex) + if (this[lastIndex] != route) { + this[lastIndex] = route } } else { 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.replaceAll(routes: List) { + 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) + } +} diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt new file mode 100644 index 000000000..60ba3f6eb --- /dev/null +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt @@ -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 . + */ +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().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) } + val mapStack = NavBackStack().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().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().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().apply { addAll(listOf(TopLevelDestination.Map.route)) } + val connectionsStack = NavBackStack().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().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()) + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index d411a2b65..a50d13d44 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -54,8 +54,12 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) + implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) implementation(libs.jetbrains.navigationevent.compose) 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) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt deleted file mode 100644 index 415937ccc..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveListDetailScaffold.kt +++ /dev/null @@ -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 . - */ -package org.meshtastic.core.ui.component - -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.key -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalFocusManager -import androidx.navigationevent.NavigationEventInfo -import androidx.navigationevent.compose.NavigationBackHandler -import androidx.navigationevent.compose.rememberNavigationEventState -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Composable -fun AdaptiveListDetailScaffold( - navigator: ThreePaneScaffoldNavigator, - scrollToTopEvents: Flow, - onBackToGraph: () -> Unit, - onTabPressedEvent: (ScrollToTopEvent) -> Boolean, - initialKey: T? = null, - listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit, - detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit, - emptyDetailPane: @Composable () -> Unit, -) { - val scope = rememberCoroutineScope() - val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange - - val handleBack: () -> Unit = { - if (navigator.canNavigateBack(backNavigationBehavior)) { - scope.launch { navigator.navigateBack(backNavigationBehavior) } - } else { - onBackToGraph() - } - } - - val navState = rememberNavigationEventState(NavigationEventInfo.None) - NavigationBackHandler( - state = navState, - isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail, - onBackCancelled = {}, - onBackCompleted = { handleBack() }, - ) - - LaunchedEffect(initialKey) { - if (initialKey != null) { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey) - } - } - - LaunchedEffect(scrollToTopEvents) { - scrollToTopEvents.collect { event -> - if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { - if (navigator.canNavigateBack(backNavigationBehavior)) { - navigator.navigateBack(backNavigationBehavior) - } else { - navigator.navigateTo(ListDetailPaneScaffoldRole.List) - } - } - } - } - - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - listPane = { - AnimatedPane { - val focusManager = LocalFocusManager.current - // Prevent TextFields from auto-focusing when pane animates in - LaunchedEffect(Unit) { focusManager.clearFocus() } - - listPane( - navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List, - navigator.currentDestination?.contentKey, - ) - } - }, - detailPane = { - AnimatedPane { - val focusManager = LocalFocusManager.current - - navigator.currentDestination?.contentKey?.let { contentKey -> - key(contentKey) { - LaunchedEffect(contentKey) { focusManager.clearFocus() } - detailPane(contentKey, handleBack) - } - } ?: emptyDetailPane() - } - }, - ) -} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt index 046a22bd0..6ade7e3b2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -16,13 +16,10 @@ */ package org.meshtastic.core.ui.component -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.NodeDetailRoutes import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -34,22 +31,21 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel */ @Composable fun MeshtasticAppShell( - backStack: NavBackStack, + multiBackstack: MultiBackstack, uiViewModel: UIViewModel, - hostModifier: Modifier = Modifier.padding(bottom = 16.dp), + hostModifier: Modifier = Modifier, content: @Composable () -> Unit, ) { LaunchedEffect(uiViewModel) { - uiViewModel.navigationDeepLink.collect { navKeys -> - backStack.clear() - backStack.addAll(navKeys) - } + uiViewModel.navigationDeepLink.collect { navKeys -> multiBackstack.handleDeepLink(navKeys) } } MeshtasticCommonAppSetup( uiViewModel = uiViewModel, 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), + ) }, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt new file mode 100644 index 000000000..42797cee5 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt @@ -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 . + */ +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, + 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, + onBack: (() -> Unit)? = null, + entryProvider: (key: NavKey) -> NavEntry, + modifier: Modifier = Modifier, +) { + val listDetailSceneStrategy = + rememberListDetailSceneStrategy( + paneExpansionState = rememberPaneExpansionState(), + paneExpansionDragHandle = { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = + Modifier.paneExpansionDraggable( + state = state, + minTouchTargetSize = 48.dp, + interactionSource = interactionSource, + ), + interactionSource = interactionSource, + ) + }, + ) + val supportingPaneSceneStrategy = + rememberSupportingPaneSceneStrategy( + paneExpansionState = rememberPaneExpansionState(), + paneExpansionDragHandle = { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = + Modifier.paneExpansionDraggable( + state = state, + minTouchTargetSize = 48.dp, + interactionSource = interactionSource, + ), + interactionSource = interactionSource, + ) + }, + ) + + val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator() + val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator() + + 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>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)), + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)), + ) +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt index ddcdfe1ff..39f8fc6b1 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt @@ -22,27 +22,22 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Box 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.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor 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.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults 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.runtime.Composable import androidx.compose.runtime.getValue @@ -51,16 +46,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.navigation.ContactsRoutes +import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.TopLevelDestination -import org.meshtastic.core.navigation.navigateTopLevel import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting @@ -70,13 +62,15 @@ import org.meshtastic.core.ui.navigation.icon 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 - * desktop targets. + * Shared adaptive navigation shell using [NavigationSuiteScaffold]. + * + * 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) @Composable fun MeshtasticNavigationSuite( - backStack: NavBackStack, + multiBackstack: MultiBackstack, uiViewModel: UIViewModel, modifier: Modifier = Modifier, content: @Composable () -> Unit, @@ -86,60 +80,69 @@ fun MeshtasticNavigationSuite( val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle() 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 -> - handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel) - } + val currentTabRoute = multiBackstack.currentTabRoute + val topLevelDestination = TopLevelDestination.fromNavKey(currentTabRoute) - if (isCompact) { - Scaffold( - modifier = modifier, - bottomBar = { - MeshtasticNavigationBar( - topLevelDestination = topLevelDestination, - connectionState = connectionState, - unreadMessageCount = unreadMessageCount, - selectedDevice = selectedDevice, - uiViewModel = uiViewModel, - onNavigate = onNavigate, + val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType() + val showLabels = layoutType == NavigationSuiteType.NavigationRail + + NavigationSuiteScaffold( + modifier = modifier, + layoutType = layoutType, + navigationSuiteItems = { + TopLevelDestination.entries.forEach { destination -> + val isSelected = destination == topLevelDestination + item( + 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() } - } - } 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() } - } + } + }, + ) { + Row { 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( destination: TopLevelDestination, topLevelDestination: TopLevelDestination?, - currentKey: NavKey?, - backStack: NavBackStack, + multiBackstack: MultiBackstack, uiViewModel: UIViewModel, ) { val isRepress = destination == topLevelDestination if (isRepress) { + val currentKey = multiBackstack.activeBackStack.lastOrNull() when (destination) { TopLevelDestination.Nodes -> { val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes if (!onNodesList) { - backStack.navigateTopLevel(destination.route) + multiBackstack.navigateTopLevel(destination.route) } else { uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed) } @@ -148,78 +151,19 @@ private fun handleNavigation( val onConversationsList = currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts if (!onConversationsList) { - backStack.navigateTopLevel(destination.route) + multiBackstack.navigateTopLevel(destination.route) } else { uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed) } } else -> { if (currentKey != destination.route) { - backStack.navigateTopLevel(destination.route) + multiBackstack.navigateTopLevel(destination.route) } } } } else { - backStack.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)) }, - ) - } + multiBackstack.navigateTopLevel(destination.route) } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index c535b4696..bd9329526 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -155,6 +155,7 @@ dependencies { implementation(projects.core.di) implementation(projects.core.model) implementation(projects.core.navigation) + implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(projects.core.repository) implementation(projects.core.domain) implementation(projects.core.data) @@ -192,8 +193,8 @@ dependencies { // Navigation 3 (JetBrains fork — multiplatform) implementation(libs.jetbrains.navigation3.ui) - implementation(libs.jetbrains.lifecycle.viewmodel.compose) implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) + implementation(libs.jetbrains.lifecycle.viewmodel.compose) implementation(libs.jetbrains.lifecycle.runtime.compose) // Koin DI diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index e326c102d..ea8562e21 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -36,20 +36,16 @@ import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType 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.type import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Notification import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.rememberNavBackStack import co.touchlab.kermit.Logger import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory @@ -63,10 +59,9 @@ import okio.Path.Companion.toPath import org.jetbrains.skia.Image import org.koin.core.context.startKoin import org.meshtastic.core.common.util.MeshtasticUri -import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig import org.meshtastic.core.navigation.SettingsRoutes 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.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme @@ -78,32 +73,12 @@ import org.meshtastic.desktop.ui.DesktopMainScreen import java.awt.Desktop import java.util.Locale -/** - * 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. - */ +/** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */ private val LocalAppLocale = staticCompositionLocalOf { "" } private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 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 private fun classpathPainterResource(path: String): Painter { val bitmap: ImageBitmap = @@ -145,7 +120,6 @@ fun main(args: Array) = application(exitProcessOnExit = false) { } } - // Start the mesh service processing chain (desktop equivalent of Android's MeshService) val meshServiceController = remember { koinApp.koin.get() } DisposableEffect(Unit) { meshServiceController.start() @@ -153,18 +127,15 @@ fun main(args: Array) = application(exitProcessOnExit = false) { } val uiPrefs = remember { koinApp.koin.get() } - 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 = "") - // 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) val isDarkTheme = when (themePref) { - 1 -> false // MODE_NIGHT_NO - 2 -> true // MODE_NIGHT_YES + 1 -> false + 2 -> true else -> isSystemInDarkTheme() } @@ -184,10 +155,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { val windowState = rememberWindowState() LaunchedEffect(Unit) { - notificationManager.notifications.collect { notification -> - Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" } - trayState.sendNotification(notification) - } + notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) } } LaunchedEffect(Unit) { @@ -223,25 +191,13 @@ fun main(args: Array) = application(exitProcessOnExit = false) { onAction = { isAppVisible = true }, menu = { 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) }, ) if (isWindowReady && isAppVisible) { - val backStack = - rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey) + val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) + val backStack = multiBackstack.activeBackStack Window( onCloseRequest = { isAppVisible = false }, @@ -251,46 +207,34 @@ fun main(args: Array) = application(exitProcessOnExit = false) { onPreviewKeyEvent = { event -> if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false when { - // ⌘Q → Quit event.key == Key.Q -> { exitApplication() true } - // ⌘, → Settings event.key == Key.Comma -> { if ( TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) ) { - backStack.navigateTopLevel(TopLevelDestination.Settings.route) + multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) } true } - // ⌘⇧T → Toggle theme - event.key == Key.T && event.isShiftPressed -> { - uiPrefs.setTheme(if (isDarkTheme) 1 else 2) - true - } - // ⌘1 → Conversations event.key == Key.One -> { - backStack.navigateTopLevel(TopLevelDestination.Conversations.route) + multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) true } - // ⌘2 → Nodes event.key == Key.Two -> { - backStack.navigateTopLevel(TopLevelDestination.Nodes.route) + multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) true } - // ⌘3 → Map event.key == Key.Three -> { - backStack.navigateTopLevel(TopLevelDestination.Map.route) + multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) true } - // ⌘4 → Connections event.key == Key.Four -> { - backStack.navigateTopLevel(TopLevelDestination.Connections.route) + multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) true } - // ⌘/ → About event.key == Key.Slash -> { backStack.add(SettingsRoutes.About) true @@ -299,14 +243,12 @@ fun main(args: Array) = 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 -> - val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache" + val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3" ImageLoader.Builder(context) .components { add(KtorNetworkFetcherFactory()) - add(SvgDecoder.Factory()) + add(SvgDecoder.Factory(renderToBitmap = false)) } .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } .diskCache { @@ -316,12 +258,8 @@ fun main(args: Array) = application(exitProcessOnExit = false) { .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) { - AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(backStack) } + AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 082512ac4..00b2e82c7 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -18,45 +18,38 @@ package org.meshtastic.desktop.ui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.ui.NavDisplay -import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.navigation.MultiBackstack 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.desktop.navigation.desktopNavGraph -/** - * 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. - */ +/** Desktop main screen — uses shared navigation components. */ @Composable -@Suppress("LongMethod") -fun DesktopMainScreen(backStack: NavBackStack, uiViewModel: UIViewModel = koinViewModel()) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { +fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) { + val backStack = multiBackstack.activeBackStack + + Surface(modifier = Modifier.fillMaxSize()) { MeshtasticAppShell( - backStack = backStack, + multiBackstack = multiBackstack, uiViewModel = uiViewModel, hostModifier = Modifier.padding(bottom = 24.dp), ) { - org.meshtastic.core.ui.component.MeshtasticNavigationSuite( - backStack = backStack, + MeshtasticNavigationSuite( + multiBackstack = multiBackstack, uiViewModel = uiViewModel, + modifier = Modifier.fillMaxSize(), ) { val provider = entryProvider { desktopNavGraph(backStack, uiViewModel) } - - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, + MeshtasticNavDisplay( + multiBackstack = multiBackstack, entryProvider = provider, modifier = Modifier.fillMaxSize(), ) diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md index 2767375b2..bbb4f62e1 100644 --- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md +++ b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md @@ -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. - 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. +- **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) diff --git a/docs/decisions/navigation3-api-alignment-2026-03.md b/docs/decisions/navigation3-api-alignment-2026-03.md new file mode 100644 index 000000000..a7f6452d8 --- /dev/null +++ b/docs/decisions/navigation3-api-alignment-2026-03.md @@ -0,0 +1,128 @@ + + +# 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>` | Allows NavDisplay to render multi-pane Scenes | ❌ Not used — defaulting to `SinglePaneSceneStrategy` | +| `SceneStrategy` interface | Custom scene calculation from backstack entries | ❌ Not used | +| `DialogSceneStrategy` | Renders `entry(metadata = dialog())` entries as overlay Dialogs | ❌ Not used — dialogs handled manually | +| `SceneDecoratorStrategy` | 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>` | 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 { entry { ... } }` | 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` 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` 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(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(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` diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md index a314af54d..c5633a6ee 100644 --- a/docs/decisions/navigation3-parity-2026-03.md +++ b/docs/decisions/navigation3-parity-2026-03.md @@ -36,19 +36,28 @@ Both modules still define separate graph-builder files (`app/navigation/*.kt`, ` 4. **Predictive back handling is KMP native.** - Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`. -## Alpha04 Changelog Impact Check (2026-03-13) +## 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.** - - 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. -2. **Primary risk is dependency wiring drift, not runtime behavior.** +> **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. + +1. **NavDisplay API updated to Scene-based architecture.** + - The `sceneStrategy: SceneStrategy` parameter is deprecated in favor of `sceneStrategies: List>`. + - New `sceneDecoratorStrategies: List>` 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` 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. -3. **Saved-state and typed-route parity risk remains unchanged.** - - Desktop still uses manual serializer registration; this is an existing risk and not introduced by alpha04. -4. **Compose-wide migration notes do not currently impact navigation codepaths.** - - `Shader` wrapper changes and `Canvas.nativeCanvas` deprecations are not used in the Navigation 3 shell files. + - 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`). +5. **Saved-state and typed-route parity risk remains unchanged.** + - Desktop still uses manual serializer registration; this is an existing risk and not introduced by beta01. +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 diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 8f3db2fc5..470d8e565 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -155,14 +155,19 @@ Remaining to be extracted from `:app` or unified in `commonMain`: | Dependency | Version | Why | |---|---|---| -| Compose Multiplatform | `1.11.0-alpha04` | Required for JetBrains Adaptive `1.3.0-alpha06` | -| Koin | `4.2.0-RC2` | Nav3 + K2 compiler plugin support | -| JetBrains Lifecycle | `2.10.0-beta01` | Multiplatform ViewModel/lifecycle | -| JetBrains Navigation 3 | `1.1.0-alpha04` | Multiplatform navigation | +| Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` | +| Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` | +| Koin | `4.2.0` | Nav3 + K2 compiler plugin support | +| 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 | **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 - Roadmap: [`docs/roadmap.md`](./roadmap.md) diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt index 943818301..75756cfaa 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt @@ -19,13 +19,12 @@ package org.meshtastic.feature.intro import android.Manifest import android.os.Build import androidx.compose.runtime.Composable -import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.rememberMultiplePermissionsState 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. @@ -58,9 +57,8 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) { val backStack = rememberNavBackStack(Welcome) - NavDisplay( + MeshtasticNavDisplay( backStack = backStack, - onBack = { backStack.removeLastOrNull() }, entryProvider = introNavGraph( backStack = backStack, diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index b517434d2..80e9d8c8c 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) + implementation(libs.jetbrains.compose.material3.adaptive.navigation3) } androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 94794465d..4c79ddd8c 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -16,6 +16,8 @@ */ 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.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ContactsRoutes +import org.meshtastic.core.navigation.replaceLast import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.feature.messaging.QuickChatScreen 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.sharing.ShareScreen +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Suppress("LongMethod") fun EntryProviderScope.contactsGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), ) { - entry { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) } - entry { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) } - entry { args -> - ContactsEntryContent( - backStack = backStack, - scrollToTopEvents = scrollToTopEvents, - initialContactKey = args.contactKey, - initialMessage = args.message, + entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> + val contactKey = args.contactKey + val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel = + koinViewModel(key = "messages-$contactKey") + messageViewModel.setContactKey(contactKey) + + 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 { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val message = args.message val viewModel = koinViewModel() ShareScreen( viewModel = viewModel, - onConfirm = { - // Navigation 3 - replace Top with Messages manually, but for now we just pop and add - backStack.removeLastOrNull() - backStack.add(ContactsRoutes.Messages(it, message)) - }, + onConfirm = { contactKey -> backStack.replaceLast(ContactsRoutes.Messages(contactKey, message)) }, onNavigateUp = { backStack.removeLastOrNull() }, ) } - entry { + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { val viewModel = koinViewModel() QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) } } @Composable -fun ContactsEntryContent( - backStack: NavBackStack, - scrollToTopEvents: Flow, - initialContactKey: String? = null, - initialMessage: String = "", -) { +fun ContactsEntryContent(backStack: NavBackStack, scrollToTopEvents: Flow) { val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel() val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() @@ -90,30 +92,11 @@ fun ContactsEntryContent( AdaptiveContactsScreen( backStack = backStack, contactsViewModel = contactsViewModel, - messageViewModel = koinViewModel(), // Ignored by custom detail pane below scrollToTopEvents = scrollToTopEvents, sharedContactRequested = sharedContactRequested, requestChannelSet = requestChannelSet, onHandleDeepLink = uiViewModel::handleDeepLink, onClearSharedContactRequested = uiViewModel::clearSharedContactRequested, 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() }, - ) - }, ) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index 06dd0c69a..441042e66 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -16,118 +16,41 @@ */ 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.rememberCoroutineScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey 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.navigation.ChannelsRoutes import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.conversations -import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold -import org.meshtastic.core.ui.component.EmptyDetailPlaceholder import org.meshtastic.core.ui.component.ScrollToTopEvent -import org.meshtastic.core.ui.icon.Conversations -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.feature.messaging.MessageScreen -import org.meshtastic.feature.messaging.MessageViewModel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact -@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod") -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun AdaptiveContactsScreen( backStack: NavBackStack, contactsViewModel: ContactsViewModel, - messageViewModel: MessageViewModel, scrollToTopEvents: Flow, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, - initialContactKey: String? = null, - initialMessage: String = "", - detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null, ) { - val navigator = rememberListDetailPaneScaffoldNavigator() - val scope = rememberCoroutineScope() - - val onBackToGraph: () -> Unit = { - val currentKey = backStack.lastOrNull() - - if ( - currentKey is ContactsRoutes.Messages || - currentKey is ContactsRoutes.Contacts || - currentKey is ContactsRoutes.ContactsGraph - ) { - // 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, + 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 -> backStack.add(ContactsRoutes.Messages(contactKey)) }, + onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, scrollToTopEvents = scrollToTopEvents, - onBackToGraph = onBackToGraph, - 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), - ) - }, + activeContactKey = null, ) } diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 56c06606a..7a455abe9 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -59,6 +59,7 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) + implementation(libs.jetbrains.compose.material3.adaptive.navigation3) } androidMain.dependencies { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index 6316ec715..ce8bd665e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -16,93 +16,31 @@ */ 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.rememberCoroutineScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ChannelsRoutes import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.core.navigation.Route -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.nodes -import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold -import org.meshtastic.core.ui.component.EmptyDetailPlaceholder 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.NodeListViewModel -@Suppress("LongMethod") -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun AdaptiveNodeListScreen( backStack: NavBackStack, scrollToTopEvents: Flow, - initialNodeId: Int? = null, - onNavigate: (Route) -> Unit = {}, - onNavigateToMessages: (String) -> Unit = {}, onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() - val navigator = rememberListDetailPaneScaffoldNavigator() - val scope = rememberCoroutineScope() - val onBackToGraph: () -> Unit = { - val currentKey = backStack.lastOrNull() - val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph - val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null - 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, + NodeListScreen( + viewModel = nodeListViewModel, + navigateToNodeDetails = { nodeId -> backStack.add(NodesRoutes.NodeDetail(nodeId)) }, + onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, scrollToTopEvents = scrollToTopEvents, - onBackToGraph = onBackToGraph, - onTabPressedEvent = { it is ScrollToTopEvent.NodesTabPressed }, - initialKey = initialNodeId, - listPane = { isActive, activeNodeId -> - NodeListScreen( - viewModel = nodeListViewModel, - navigateToNodeDetails = { nodeId -> - scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) } - }, - onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, - scrollToTopEvents = scrollToTopEvents, - activeNodeId = activeNodeId, - 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)) - }, + activeNodeId = null, + onHandleDeepLink = onHandleDeepLink, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 48789342f..276e2892e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -26,6 +26,8 @@ import androidx.compose.material.icons.rounded.People import androidx.compose.material.icons.rounded.PermScanWifi import androidx.compose.material.icons.rounded.Power 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.ui.graphics.vector.ImageVector 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.traceroute 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.EnvironmentMetricsScreen 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 kotlin.reflect.KClass +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Suppress("LongMethod") fun EntryProviderScope.nodesGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { - entry { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, - onNavigate = { backStack.add(it) }, - onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, onHandleDeepLink = onHandleDeepLink, ) } - entry { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, - onNavigate = { backStack.add(it) }, - onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, onHandleDeepLink = onHandleDeepLink, ) } @@ -92,42 +94,42 @@ fun EntryProviderScope.nodesGraph( nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink) } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Suppress("LongMethod") fun EntryProviderScope.nodeDetailGraph( backStack: NavBackStack, scrollToTopEvents: Flow, onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { - entry { args -> + entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, - initialNodeId = args.destNum, - onNavigate = { backStack.add(it) }, - onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, onHandleDeepLink = onHandleDeepLink, ) } - entry { args -> - AdaptiveNodeListScreen( - backStack = backStack, - scrollToTopEvents = scrollToTopEvents, - initialNodeId = args.destNum, + entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> + val nodeDetailViewModel: NodeDetailViewModel = koinViewModel() + val compassViewModel: CompassViewModel = koinViewModel() + val destNum = args.destNum ?: 0 // Handle nullable destNum if needed + NodeDetailScreen( + nodeId = destNum, + viewModel = nodeDetailViewModel, + compassViewModel = compassViewModel, + navigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, onNavigate = { backStack.add(it) }, - onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) }, - onHandleDeepLink = onHandleDeepLink, + onNavigateUp = { backStack.removeLastOrNull() }, ) } - entry { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current mapScreen(args.destNum) { backStack.removeLastOrNull() } } - entry { args -> - val metricsViewModel = - koinViewModel(key = "metrics-${args.destNum}") { parametersOf(args.destNum) } + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> + val metricsViewModel = koinViewModel { parametersOf(args.destNum) } metricsViewModel.setNodeId(args.destNum) TracerouteLogScreen( @@ -145,7 +147,7 @@ fun EntryProviderScope.nodeDetailGraph( ) } - entry { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() } } @@ -175,14 +177,15 @@ fun EntryProviderScope.nodeDetailGraph( fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) private inline fun EntryProviderScope.addNodeDetailScreenComposable( backStack: NavBackStack, routeInfo: NodeDetailRoute, crossinline getDestNum: (R) -> Int, ) { - entry { args -> + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val destNum = getDestNum(args) - val metricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) } + val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99b667e9d..762c4c4ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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-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-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 firebase-analytics = { module = "com.google.firebase:firebase-analytics" }