Refactor nav3 architecture and enhance adaptive layouts (#4944)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-27 09:43:44 -05:00
committed by GitHub
parent 3feec759a1
commit f2d09ff79d
29 changed files with 740 additions and 617 deletions

View File

@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core: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<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **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.

View File

@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core: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<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **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.

View File

@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core: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<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **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`.

View File

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

View File

@@ -139,7 +139,7 @@ class MainActivity : ComponentActivity() {
ReportDrawnWhen { true }
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
MainScreen()
} else {
val introViewModel = koinViewModel<IntroViewModel>()
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<org.meshtastic.feature.node.metrics.MetricsViewModel>(key = "metrics-$destNum") {
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
org.koin.core.parameter.parametersOf(destNum)
}
metricsViewModel.setNodeId(destNum)

View File

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

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
/** Manages independent backstacks for multiple tabs. */
class MultiBackstack(val startTab: NavKey) {
var backStacks: Map<NavKey, NavBackStack<NavKey>> = emptyMap()
var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab)
private set
val activeBackStack: NavBackStack<NavKey>
get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found")
/** Switches to a new top-level tab route. */
fun navigateTopLevel(route: NavKey) {
val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route
if (currentTabRoute == rootKey) {
// Repressing the same tab resets its stack to just the root
activeBackStack.replaceAll(listOf(rootKey))
} else {
// Switching to a different tab
currentTabRoute = rootKey
}
}
/** Handles back navigation according to the "exit through home" pattern. */
fun goBack() {
val currentStack = activeBackStack
if (currentStack.size > 1) {
currentStack.removeLastOrNull()
return
}
// If we're at the root of a non-start tab, switch back to the start tab
if (currentTabRoute != startTab) {
currentTabRoute = startTab
}
}
/** Sets the active tab and replaces its stack with the provided route path. */
fun handleDeepLink(navKeys: List<NavKey>) {
val rootKey = navKeys.firstOrNull() ?: return
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
currentTabRoute = topLevel
val stack = backStacks[topLevel] ?: return
stack.replaceAll(navKeys)
}
}
/** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */
@Composable
fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack {
val stacks = mutableMapOf<NavKey, NavBackStack<NavKey>>()
TopLevelDestination.entries.forEach { dest ->
key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) }
}
val multiBackstack = remember { MultiBackstack(initialTab) }
multiBackstack.backStacks = stacks
return multiBackstack
}

View File

@@ -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<NavKey>.navigateTopLevel(route: NavKey) {
fun MutableList<NavKey>.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<NavKey>.replaceAll(routes: List<NavKey>) {
if (routes.isEmpty()) {
clear()
return
}
for (i in routes.indices) {
if (i < size) {
// Only mutate if the route actually changed, protecting Nav3's internal state matching.
if (this[i] != routes[i]) {
this[i] = routes[i]
}
} else {
add(routes[i])
}
}
while (size > routes.size) {
removeAt(lastIndex)
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlin.test.Test
import kotlin.test.assertEquals
class MultiBackstackTest {
@Test
fun `navigateTopLevel to different tab preserves previous tab stack and activates new tab stack`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
multiBackstack.backStacks =
mapOf(TopLevelDestination.Nodes.route to nodesStack, TopLevelDestination.Map.route to mapStack)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
assertEquals(2, multiBackstack.activeBackStack.size)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(2, nodesStack.size)
}
@Test
fun `navigateTopLevel to same tab resets stack to root`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
assertEquals(2, multiBackstack.activeBackStack.size)
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
}
@Test
fun `goBack pops current stack if size is greater than 1`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
multiBackstack.goBack()
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
}
@Test
fun `goBack on root of non-start tab returns to start tab`() {
val startTab = TopLevelDestination.Connections.route
val multiBackstack = MultiBackstack(startTab)
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
val connectionsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
multiBackstack.backStacks =
mapOf(TopLevelDestination.Map.route to mapStack, TopLevelDestination.Connections.route to connectionsStack)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
multiBackstack.goBack()
assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
}
@Test
fun `handleDeepLink sets target tab and populates stack`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val settingsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Settings.route)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack)
val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoutes.About)
multiBackstack.handleDeepLink(deepLinkPath)
assertEquals(TopLevelDestination.Settings.route, multiBackstack.currentTabRoute)
assertEquals(2, multiBackstack.activeBackStack.size)
assertEquals(SettingsRoutes.About, multiBackstack.activeBackStack.last())
}
}

View File

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

View File

@@ -1,113 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalFocusManager
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun <T> AdaptiveListDetailScaffold(
navigator: ThreePaneScaffoldNavigator<T>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onBackToGraph: () -> Unit,
onTabPressedEvent: (ScrollToTopEvent) -> Boolean,
initialKey: T? = null,
listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit,
detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit,
emptyDetailPane: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
val handleBack: () -> Unit = {
if (navigator.canNavigateBack(backNavigationBehavior)) {
scope.launch { navigator.navigateBack(backNavigationBehavior) }
} else {
onBackToGraph()
}
}
val navState = rememberNavigationEventState(NavigationEventInfo.None)
NavigationBackHandler(
state = navState,
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
onBackCancelled = {},
onBackCompleted = { handleBack() },
)
LaunchedEffect(initialKey) {
if (initialKey != null) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey)
}
}
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents.collect { event ->
if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) {
if (navigator.canNavigateBack(backNavigationBehavior)) {
navigator.navigateBack(backNavigationBehavior)
} else {
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
}
}
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
val focusManager = LocalFocusManager.current
// Prevent TextFields from auto-focusing when pane animates in
LaunchedEffect(Unit) { focusManager.clearFocus() }
listPane(
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List,
navigator.currentDestination?.contentKey,
)
}
},
detailPane = {
AnimatedPane {
val focusManager = LocalFocusManager.current
navigator.currentDestination?.contentKey?.let { contentKey ->
key(contentKey) {
LaunchedEffect(contentKey) { focusManager.clearFocus() }
detailPane(contentKey, handleBack)
}
} ?: emptyDetailPane()
}
},
)
}

View File

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

View File

@@ -0,0 +1,148 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.VerticalDragHandle
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.DialogSceneStrategy
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SinglePaneSceneStrategy
import androidx.navigation3.ui.NavDisplay
import org.meshtastic.core.navigation.MultiBackstack
/** Duration in milliseconds for the shared crossfade transition between navigation scenes. */
private const val TRANSITION_DURATION_MS = 350
/**
* Shared [NavDisplay] wrapper that configures the standard Meshtastic entry decorators, scene strategies, and
* transition animations for all platform hosts.
*
* This version supports multiple backstacks by accepting a [MultiBackstack] state holder.
*/
@Composable
fun MeshtasticNavDisplay(
multiBackstack: MultiBackstack,
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
modifier: Modifier = Modifier,
) {
val backStack = multiBackstack.activeBackStack
MeshtasticNavDisplay(
backStack = backStack,
onBack = { multiBackstack.goBack() },
entryProvider = entryProvider,
modifier = modifier,
)
}
/** Shared [NavDisplay] wrapper for a single backstack. */
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun MeshtasticNavDisplay(
backStack: NavBackStack<NavKey>,
onBack: (() -> Unit)? = null,
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
modifier: Modifier = Modifier,
) {
val listDetailSceneStrategy =
rememberListDetailSceneStrategy<NavKey>(
paneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle = { state ->
val interactionSource = remember { MutableInteractionSource() }
VerticalDragHandle(
modifier =
Modifier.paneExpansionDraggable(
state = state,
minTouchTargetSize = 48.dp,
interactionSource = interactionSource,
),
interactionSource = interactionSource,
)
},
)
val supportingPaneSceneStrategy =
rememberSupportingPaneSceneStrategy<NavKey>(
paneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle = { state ->
val interactionSource = remember { MutableInteractionSource() }
VerticalDragHandle(
modifier =
Modifier.paneExpansionDraggable(
state = state,
minTouchTargetSize = 48.dp,
interactionSource = interactionSource,
),
interactionSource = interactionSource,
)
},
)
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<NavKey>()
val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator<NavKey>()
val activeDecorators =
remember(backStack, saveableDecorator, vmStoreDecorator) { listOf(saveableDecorator, vmStoreDecorator) }
NavDisplay(
backStack = backStack,
entryProvider = entryProvider,
entryDecorators = activeDecorators,
onBack =
onBack
?: {
if (backStack.size > 1) {
backStack.removeLastOrNull()
}
},
sceneStrategies =
listOf(
DialogSceneStrategy(),
listDetailSceneStrategy,
supportingPaneSceneStrategy,
SinglePaneSceneStrategy(),
),
transitionSpec = meshtasticTransitionSpec(),
popTransitionSpec = meshtasticTransitionSpec(),
modifier = modifier,
)
}
/** Shared crossfade [ContentTransform] used for both forward and pop navigation. */
private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
ContentTransform(
fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)),
fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)),
)
}

View File

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

View File

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

View File

@@ -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<String>) = application(exitProcessOnExit = false) {
}
}
// Start the mesh service processing chain (desktop equivalent of Android's MeshService)
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
DisposableEffect(Unit) {
meshServiceController.start()
@@ -153,18 +127,15 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
val themePref by uiPrefs.theme.collectAsState(initial = -1) // -1 is SYSTEM usually
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "")
// 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<String>) = 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<String>) = 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<String>) = 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<String>) = application(exitProcessOnExit = false) {
}
},
) {
// Configure Coil ImageLoader for desktop with SVG decoding and network fetching.
// This is the desktop equivalent of the Android app's NetworkModule.provideImageLoader().
setSingletonImageLoaderFactory { context ->
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<String>) = 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) }
}
}
}

View File

@@ -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<NavKey>, 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<NavKey> { desktopNavGraph(backStack, uiViewModel) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
MeshtasticNavDisplay(
multiBackstack = multiBackstack,
entryProvider = provider,
modifier = Modifier.fillMaxSize(),
)

View File

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

View File

@@ -0,0 +1,128 @@
<!--
- Copyright (c) 2026 Meshtastic LLC
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-->
# Navigation 3 & Material 3 Adaptive — API Alignment Audit
**Date:** 2026-03-26
**Status:** Active
**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration.
**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated).
## Current Dependency Baseline
| Library | Version | Group |
|---|---|---|
| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` |
| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` |
| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` |
| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` |
| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` |
| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` |
| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` |
## API Audit: What's Available vs. What We Use
### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`)
**Available APIs we're NOT using:**
| API | Purpose | Status in project |
|---|---|---|
| `sceneStrategies: List<SceneStrategy<T>>` | Allows NavDisplay to render multi-pane Scenes | ❌ Not used — defaulting to `SinglePaneSceneStrategy` |
| `SceneStrategy<T>` interface | Custom scene calculation from backstack entries | ❌ Not used |
| `DialogSceneStrategy` | Renders `entry<T>(metadata = dialog())` entries as overlay Dialogs | ❌ Not used — dialogs handled manually |
| `SceneDecoratorStrategy<T>` | Wraps/decorates scenes with additional UI | ❌ Not used |
| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ❌ Not used |
| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used |
| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ❌ Not used — no transitions at all |
| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used |
| `entryDecorators: List<NavEntryDecorator<T>>` | Wraps entry content with additional behavior | ❌ Not used (defaulting to `SaveableStateHolderNavEntryDecorator`) |
**APIs we ARE using correctly:**
| API | Usage |
|---|---|
| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` |
| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence |
| `entryProvider<NavKey> { entry<T> { ... } }` | All feature graph registrations |
| `NavigationBackHandler` from `navigationevent-compose` | Used in `AdaptiveListDetailScaffold` |
### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`)
**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project declares this dependency in `desktop/build.gradle.kts` but does **not** pass it as an `entryDecorator` to `NavDisplay`.
Currently, `koinViewModel()` calls inside `entry<T>` blocks use the nearest `ViewModelStoreOwner` from the composition — which is the Activity/Window level. This means:
- ViewModels are **not** automatically cleared when their entry is popped from the backstack.
- The project works around this with manual `key = "metrics-$destNum"` parameter keying.
**Opportunity:** Adding `rememberViewModelStoreNavEntryDecorator()` to `NavDisplay.entryDecorators` would give each backstack entry its own `ViewModelStoreOwner`, so `koinViewModel()` calls would be automatically scoped to the entry's lifetime.
### 3. Material 3 Adaptive — Nav3 Scene Integration
**Key finding:** The JetBrains `adaptive-navigation` artifact at `1.3.0-alpha06` does **NOT** include `MaterialListDetailSceneStrategy`. That API only exists in the Google AndroidX version (`androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha09+`).
This means the project **cannot** currently use the official M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. The current approach of hosting `ListDetailPaneScaffold` inside `entry<T>` blocks (via `AdaptiveListDetailScaffold`) is the correct pattern for the JetBrains fork at this version.
**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set.
### 4. NavigationSuiteScaffold (`1.11.0-alpha05`)
**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed.
## Prioritized Opportunities
### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk)
**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`.
**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time.
### P1: Add default NavDisplay transitions (medium-value, low-risk)
**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition.
**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions.
### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk)
**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry<T>(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support.
**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected.
### Consolidation: `MeshtasticNavDisplay` shared wrapper
**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration:
- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator`
- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy`
- Transition specs: 350 ms crossfade (forward + pop)
Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`.
### P3: Per-entry transition metadata (low-value until Scene adoption)
Individual entries can declare custom transitions via `entry<T>(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade).
**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption.
### Deferred: Scene-based multi-pane layout
The `MaterialListDetailSceneStrategy` is not available in the JetBrains adaptive fork at `1.3.0-alpha06`. The project's `AdaptiveListDetailScaffold` wrapper is the correct approach for now. Revisit when the JetBrains fork includes the Scene bridge, or consider writing a custom `SceneStrategy` that integrates with the existing `ListDetailPaneScaffold`.
## Decision
~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~
**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred.
## References
- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache)
- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream)
- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream)
- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06`

View File

@@ -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<T>` parameter is deprecated in favor of `sceneStrategies: List<SceneStrategy<T>>`.
- New `sceneDecoratorStrategies: List<SceneDecoratorStrategy<T>>` parameter available.
- New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions.
- Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`.
2. **Entry-scoped ViewModel lifecycle adopted.**
- Both `app` and `desktop` now pass `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` as explicit `entryDecorators` to `NavDisplay`.
- ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are now scoped to the entry's backstack lifetime.
3. **No direct Navigation 3 API breakage.**
- Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns.
4. **Primary risk is dependency wiring drift, not runtime behavior.**
- JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog.
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

View File

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

View File

@@ -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<NavKey>(
MeshtasticNavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
introNavGraph(
backStack = backStack,

View File

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

View File

@@ -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<NavKey>.contactsGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
) {
entry<ContactsRoutes.ContactsGraph> {
entry<ContactsRoutes.ContactsGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
}
entry<ContactsRoutes.Contacts> {
entry<ContactsRoutes.Contacts>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
}
entry<ContactsRoutes.Messages> { args ->
ContactsEntryContent(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialContactKey = args.contactKey,
initialMessage = args.message,
entry<ContactsRoutes.Messages>(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<ContactsRoutes.Share> { args ->
entry<ContactsRoutes.Share>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val message = args.message
val viewModel = koinViewModel<ContactsViewModel>()
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<ContactsRoutes.QuickChat> {
entry<ContactsRoutes.QuickChat>(metadata = { ListDetailSceneStrategy.extraPane() }) {
val viewModel = koinViewModel<QuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
}
@Composable
fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
fun ContactsEntryContent(backStack: NavBackStack<NavKey>, scrollToTopEvents: Flow<ScrollToTopEvent>) {
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() },
)
},
)
}

View File

@@ -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<NavKey>,
contactsViewModel: ContactsViewModel,
messageViewModel: MessageViewModel,
scrollToTopEvents: Flow<ScrollToTopEvent>,
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<String>()
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,
)
}

View File

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

View File

@@ -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<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
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<Int>()
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,
)
}

View File

@@ -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<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodesGraph> {
entry<NodesRoutes.NodesGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
entry<NodesRoutes.Nodes> {
entry<NodesRoutes.Nodes>(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<NavKey>.nodesGraph(
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodeDetailGraph> { args ->
entry<NodesRoutes.NodeDetailGraph>(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<NodesRoutes.NodeDetail> { args ->
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
entry<NodesRoutes.NodeDetail>(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<NodeDetailRoutes.NodeMap> { args ->
entry<NodeDetailRoutes.NodeMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
mapScreen(args.destNum) { backStack.removeLastOrNull() }
}
entry<NodeDetailRoutes.TracerouteLog> { args ->
val metricsViewModel =
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
entry<NodeDetailRoutes.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
@@ -145,7 +147,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
)
}
entry<NodeDetailRoutes.TracerouteMap> { args ->
entry<NodeDetailRoutes.TracerouteMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
}
@@ -175,14 +177,15 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass }
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable(
backStack: NavBackStack<NavKey>,
routeInfo: NodeDetailRoute,
crossinline getDestNum: (R) -> Int,
) {
entry<R> { args ->
entry<R>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val destNum = getDestNum(args)
val metricsViewModel = koinViewModel<MetricsViewModel>(key = "metrics-$destNum") { parametersOf(destNum) }
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }

View File

@@ -140,6 +140,8 @@ compose-multiplatform-materialIconsExtended = { module = "org.jetbrains.compose.
jetbrains-compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jetbrains-adaptive" }
jetbrains-compose-material3-adaptive-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" }