mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 18:21:58 -04:00
Refactor nav3 architecture and enhance adaptive layouts (#4944)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
|
||||
/** Manages independent backstacks for multiple tabs. */
|
||||
class MultiBackstack(val startTab: NavKey) {
|
||||
var backStacks: Map<NavKey, NavBackStack<NavKey>> = emptyMap()
|
||||
|
||||
var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab)
|
||||
private set
|
||||
|
||||
val activeBackStack: NavBackStack<NavKey>
|
||||
get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found")
|
||||
|
||||
/** Switches to a new top-level tab route. */
|
||||
fun navigateTopLevel(route: NavKey) {
|
||||
val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route
|
||||
|
||||
if (currentTabRoute == rootKey) {
|
||||
// Repressing the same tab resets its stack to just the root
|
||||
activeBackStack.replaceAll(listOf(rootKey))
|
||||
} else {
|
||||
// Switching to a different tab
|
||||
currentTabRoute = rootKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles back navigation according to the "exit through home" pattern. */
|
||||
fun goBack() {
|
||||
val currentStack = activeBackStack
|
||||
if (currentStack.size > 1) {
|
||||
currentStack.removeLastOrNull()
|
||||
return
|
||||
}
|
||||
|
||||
// If we're at the root of a non-start tab, switch back to the start tab
|
||||
if (currentTabRoute != startTab) {
|
||||
currentTabRoute = startTab
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the active tab and replaces its stack with the provided route path. */
|
||||
fun handleDeepLink(navKeys: List<NavKey>) {
|
||||
val rootKey = navKeys.firstOrNull() ?: return
|
||||
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
|
||||
currentTabRoute = topLevel
|
||||
val stack = backStacks[topLevel] ?: return
|
||||
stack.replaceAll(navKeys)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */
|
||||
@Composable
|
||||
fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack {
|
||||
val stacks = mutableMapOf<NavKey, NavBackStack<NavKey>>()
|
||||
|
||||
TopLevelDestination.entries.forEach { dest ->
|
||||
key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) }
|
||||
}
|
||||
|
||||
val multiBackstack = remember { MultiBackstack(initialTab) }
|
||||
multiBackstack.backStacks = stacks
|
||||
|
||||
return multiBackstack
|
||||
}
|
||||
@@ -19,16 +19,38 @@ package org.meshtastic.core.navigation
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.navigationevent.NavigationEventInfo
|
||||
import androidx.navigationevent.compose.NavigationBackHandler
|
||||
import androidx.navigationevent.compose.rememberNavigationEventState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun <T> AdaptiveListDetailScaffold(
|
||||
navigator: ThreePaneScaffoldNavigator<T>,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
onBackToGraph: () -> Unit,
|
||||
onTabPressedEvent: (ScrollToTopEvent) -> Boolean,
|
||||
initialKey: T? = null,
|
||||
listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit,
|
||||
detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit,
|
||||
emptyDetailPane: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
|
||||
|
||||
val handleBack: () -> Unit = {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
scope.launch { navigator.navigateBack(backNavigationBehavior) }
|
||||
} else {
|
||||
onBackToGraph()
|
||||
}
|
||||
}
|
||||
|
||||
val navState = rememberNavigationEventState(NavigationEventInfo.None)
|
||||
NavigationBackHandler(
|
||||
state = navState,
|
||||
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
|
||||
onBackCancelled = {},
|
||||
onBackCompleted = { handleBack() },
|
||||
)
|
||||
|
||||
LaunchedEffect(initialKey) {
|
||||
if (initialKey != null) {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents.collect { event ->
|
||||
if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
navigator.navigateBack(backNavigationBehavior)
|
||||
} else {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
val focusManager = LocalFocusManager.current
|
||||
// Prevent TextFields from auto-focusing when pane animates in
|
||||
LaunchedEffect(Unit) { focusManager.clearFocus() }
|
||||
|
||||
listPane(
|
||||
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List,
|
||||
navigator.currentDestination?.contentKey,
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
navigator.currentDestination?.contentKey?.let { contentKey ->
|
||||
key(contentKey) {
|
||||
LaunchedEffect(contentKey) { focusManager.clearFocus() }
|
||||
detailPane(contentKey, handleBack)
|
||||
}
|
||||
} ?: emptyDetailPane()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -16,13 +16,10 @@
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
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),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.VerticalDragHandle
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
|
||||
import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.scene.DialogSceneStrategy
|
||||
import androidx.navigation3.scene.Scene
|
||||
import androidx.navigation3.scene.SinglePaneSceneStrategy
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import org.meshtastic.core.navigation.MultiBackstack
|
||||
|
||||
/** Duration in milliseconds for the shared crossfade transition between navigation scenes. */
|
||||
private const val TRANSITION_DURATION_MS = 350
|
||||
|
||||
/**
|
||||
* Shared [NavDisplay] wrapper that configures the standard Meshtastic entry decorators, scene strategies, and
|
||||
* transition animations for all platform hosts.
|
||||
*
|
||||
* This version supports multiple backstacks by accepting a [MultiBackstack] state holder.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticNavDisplay(
|
||||
multiBackstack: MultiBackstack,
|
||||
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backStack = multiBackstack.activeBackStack
|
||||
MeshtasticNavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { multiBackstack.goBack() },
|
||||
entryProvider = entryProvider,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared [NavDisplay] wrapper for a single backstack. */
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun MeshtasticNavDisplay(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
onBack: (() -> Unit)? = null,
|
||||
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listDetailSceneStrategy =
|
||||
rememberListDetailSceneStrategy<NavKey>(
|
||||
paneExpansionState = rememberPaneExpansionState(),
|
||||
paneExpansionDragHandle = { state ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
VerticalDragHandle(
|
||||
modifier =
|
||||
Modifier.paneExpansionDraggable(
|
||||
state = state,
|
||||
minTouchTargetSize = 48.dp,
|
||||
interactionSource = interactionSource,
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
},
|
||||
)
|
||||
val supportingPaneSceneStrategy =
|
||||
rememberSupportingPaneSceneStrategy<NavKey>(
|
||||
paneExpansionState = rememberPaneExpansionState(),
|
||||
paneExpansionDragHandle = { state ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
VerticalDragHandle(
|
||||
modifier =
|
||||
Modifier.paneExpansionDraggable(
|
||||
state = state,
|
||||
minTouchTargetSize = 48.dp,
|
||||
interactionSource = interactionSource,
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<NavKey>()
|
||||
val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator<NavKey>()
|
||||
|
||||
val activeDecorators =
|
||||
remember(backStack, saveableDecorator, vmStoreDecorator) { listOf(saveableDecorator, vmStoreDecorator) }
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = entryProvider,
|
||||
entryDecorators = activeDecorators,
|
||||
onBack =
|
||||
onBack
|
||||
?: {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
},
|
||||
sceneStrategies =
|
||||
listOf(
|
||||
DialogSceneStrategy(),
|
||||
listDetailSceneStrategy,
|
||||
supportingPaneSceneStrategy,
|
||||
SinglePaneSceneStrategy(),
|
||||
),
|
||||
transitionSpec = meshtasticTransitionSpec(),
|
||||
popTransitionSpec = meshtasticTransitionSpec(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared crossfade [ContentTransform] used for both forward and pop navigation. */
|
||||
private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
|
||||
ContentTransform(
|
||||
fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)),
|
||||
fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)),
|
||||
)
|
||||
}
|
||||
@@ -22,27 +22,22 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
128
docs/decisions/navigation3-api-alignment-2026-03.md
Normal file
128
docs/decisions/navigation3-api-alignment-2026-03.md
Normal 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`
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user