mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
feat(ui): align multiple backstacks with Navigation 3 official recipes
Refactored the application's multi-tab navigation to strictly align with the official AndroidX `nav3-recipes` for multiple backstacks. Previously, swapping a single `NavBackStack` list instance caused Navigation 3 to perceive old entries as "popped," leading to the destruction of their associated `ViewModelStore` and UI state. This commit resolves the issue by: - Redesigning `MultiBackstack` as a state holder managing independent `NavBackStack` instances per tab. - Modifying `MeshtasticNavDisplay` to bind a fresh set of decorators (specifically `SaveableStateHolderNavEntryDecorator` and `ViewModelStoreNavEntryDecorator`) to the active backstack dynamically via `remember(backStack)`. This ensures decorators are swapped alongside the stack, preserving state for inactive tabs. - Implementing the standard "exit through home" back-handling pattern across all platforms. - Removing legacy manual string keys from `koinViewModel()` injections, as `ViewModelStoreNavEntryDecorator` now correctly isolates ViewModel scope per entry. - Added `androidx.lifecycle.viewmodel.navigation3` dependencies to the `app` and `desktop` root modules to support the decorator injections at the root composition level.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -173,8 +173,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") {
|
||||
val metricsViewModel = koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
|
||||
org.koin.core.parameter.parametersOf(destNum)
|
||||
}
|
||||
metricsViewModel.setNodeId(destNum)
|
||||
|
||||
@@ -28,13 +28,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
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
|
||||
@@ -53,12 +52,17 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val viewModel: UIViewModel = koinViewModel()
|
||||
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
|
||||
val multiBackstack = rememberMultiBackstack(NodesRoutes.NodesGraph)
|
||||
val backStack = multiBackstack.activeBackStack
|
||||
|
||||
AndroidAppVersionCheck(viewModel)
|
||||
|
||||
MeshtasticAppShell(backStack = backStack, uiViewModel = viewModel, hostModifier = Modifier) {
|
||||
MeshtasticNavigationSuite(backStack = backStack, uiViewModel = viewModel, modifier = Modifier.fillMaxSize()) {
|
||||
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
|
||||
MeshtasticNavigationSuite(
|
||||
multiBackstack = multiBackstack,
|
||||
uiViewModel = viewModel,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val provider =
|
||||
entryProvider<NavKey> {
|
||||
contactsGraph(backStack, viewModel.scrollToTopEventFlow)
|
||||
@@ -74,7 +78,7 @@ fun MainScreen() {
|
||||
firmwareGraph(backStack)
|
||||
}
|
||||
MeshtasticNavDisplay(
|
||||
backStack = backStack,
|
||||
multiBackstack = multiBackstack,
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -18,23 +18,6 @@ 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.
|
||||
*/
|
||||
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
|
||||
if (isNotEmpty()) {
|
||||
if (this[0] != route) {
|
||||
this[0] = route
|
||||
}
|
||||
while (size > 1) {
|
||||
removeAt(lastIndex)
|
||||
}
|
||||
} else {
|
||||
add(route)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the last entry in the back stack with the given route. If the back stack is empty, it simply adds the route.
|
||||
*/
|
||||
|
||||
@@ -19,10 +19,8 @@ package org.meshtastic.core.ui.component
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
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.navigation.replaceAll
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
@@ -33,17 +31,23 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticAppShell(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
multiBackstack: MultiBackstack,
|
||||
uiViewModel: UIViewModel,
|
||||
hostModifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(uiViewModel) { uiViewModel.navigationDeepLink.collect { navKeys -> backStack.replaceAll(navKeys) } }
|
||||
LaunchedEffect(uiViewModel) {
|
||||
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)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -39,12 +40,10 @@ 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.
|
||||
*
|
||||
* This is faster than the library's Android default (700 ms) and matches Material 3 motion guidance for medium-emphasis
|
||||
* container transforms (~300-400 ms).
|
||||
*/
|
||||
private const val TRANSITION_DURATION_MS = 350
|
||||
|
||||
@@ -52,30 +51,31 @@ 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.
|
||||
*
|
||||
* **Entry decorators** (applied to every backstack entry):
|
||||
* - [rememberSaveableStateHolderNavEntryDecorator] — saveable state per entry.
|
||||
* - [rememberViewModelStoreNavEntryDecorator] — entry-scoped `ViewModelStoreOwner` so that ViewModels obtained via
|
||||
* `koinViewModel()` are automatically cleared when the entry is popped.
|
||||
*
|
||||
* **Scene strategies** (evaluated in order):
|
||||
* - [DialogSceneStrategy] — entries annotated with `metadata = DialogSceneStrategy.dialog()` render as overlay
|
||||
* [Dialog][androidx.compose.ui.window.Dialog] windows with proper backstack lifecycle.
|
||||
* - [androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy] — entries annotated with `listPane()`,
|
||||
* `detailPane()`, or `extraPane()` render in adaptive list-detail layout on wider screens.
|
||||
* - [androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy] — entries annotated with
|
||||
* `mainPane()`, `supportingPane()`, or `extraPane()` render in adaptive supporting pane layout.
|
||||
* - [SinglePaneSceneStrategy] — default single-pane fallback.
|
||||
*
|
||||
* **Transitions**: A uniform 350 ms crossfade for both forward and pop navigation.
|
||||
*
|
||||
* @param backStack the navigation backstack, typically from [rememberNavBackStack].
|
||||
* @param entryProvider the entry provider built from feature navigation graphs.
|
||||
* @param modifier modifier applied to the underlying [NavDisplay].
|
||||
* 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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun MeshtasticNavDisplay(
|
||||
backStack: List<NavKey>,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
onBack: (() -> Unit)? = null,
|
||||
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -111,11 +111,23 @@ fun MeshtasticNavDisplay(
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<NavKey>()
|
||||
val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator<NavKey>()
|
||||
|
||||
val activeDecorators = remember(backStack, saveableDecorator, vmStoreDecorator) {
|
||||
listOf(saveableDecorator, vmStoreDecorator)
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = entryProvider,
|
||||
entryDecorators =
|
||||
listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||
entryDecorators = activeDecorators,
|
||||
onBack = onBack ?: {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
},
|
||||
sceneStrategies =
|
||||
listOf(
|
||||
DialogSceneStrategy(),
|
||||
@@ -130,8 +142,7 @@ fun MeshtasticNavDisplay(
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared crossfade [ContentTransform] used for both forward and pop navigation. Returns a lambda compatible with
|
||||
* [NavDisplay]'s `transitionSpec` / `popTransitionSpec` parameters.
|
||||
* Shared crossfade [ContentTransform] used for both forward and pop navigation.
|
||||
*/
|
||||
private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
|
||||
ContentTransform(
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.Row
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -45,15 +46,14 @@ 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 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
|
||||
@@ -65,14 +65,13 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
/**
|
||||
* Shared adaptive navigation shell using [NavigationSuiteScaffold].
|
||||
*
|
||||
* Automatically renders a [NavigationBar][androidx.compose.material3.NavigationBar] on compact screens and a
|
||||
* [NavigationRail][androidx.compose.material3.NavigationRail] on medium/expanded widths, without manual branching. Uses
|
||||
* [currentWindowAdaptiveInfo] with large-width breakpoint support for Desktop and External Display targets.
|
||||
* This implementation uses the [MultiBackstack] state holder to manage independent histories for each tab,
|
||||
* aligning with Navigation 3 best practices for state preservation during tab switching.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeshtasticNavigationSuite(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
multiBackstack: MultiBackstack,
|
||||
uiViewModel: UIViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
@@ -82,20 +81,11 @@ fun MeshtasticNavigationSuite(
|
||||
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val rootKey = backStack.firstOrNull()
|
||||
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
|
||||
|
||||
val currentTabRoute = multiBackstack.currentTabRoute
|
||||
val topLevelDestination = TopLevelDestination.fromNavKey(currentTabRoute)
|
||||
|
||||
val onNavigate = { destination: TopLevelDestination ->
|
||||
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
|
||||
}
|
||||
|
||||
// Cap the layout type at NavigationRail for expanded widths — we don't want a permanent
|
||||
// NavigationDrawer. NavigationSuiteScaffoldDefaults resolves COMPACT → NavigationBar,
|
||||
// MEDIUM/EXPANDED → NavigationRail already; passing the custom adaptiveInfo ensures the
|
||||
// large-width (1200dp+) breakpoints are respected.
|
||||
val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType()
|
||||
|
||||
val showLabels = layoutType == NavigationSuiteType.NavigationRail
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
@@ -103,13 +93,16 @@ fun MeshtasticNavigationSuite(
|
||||
layoutType = layoutType,
|
||||
navigationSuiteItems = {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
val isSelected = destination == topLevelDestination
|
||||
item(
|
||||
selected = destination == topLevelDestination,
|
||||
onClick = { onNavigate(destination) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
handleNavigation(destination, topLevelDestination, multiBackstack, uiViewModel)
|
||||
},
|
||||
icon = {
|
||||
NavigationIconContent(
|
||||
destination = destination,
|
||||
isSelected = destination == topLevelDestination,
|
||||
isSelected = isSelected,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
@@ -126,7 +119,7 @@ fun MeshtasticNavigationSuite(
|
||||
}
|
||||
},
|
||||
) {
|
||||
content()
|
||||
Row { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,17 +135,17 @@ private fun NavigationSuiteType.coerceNavigationType(): NavigationSuiteType = wh
|
||||
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)
|
||||
}
|
||||
@@ -161,19 +154,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)
|
||||
multiBackstack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,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)
|
||||
@@ -189,6 +190,7 @@ dependencies {
|
||||
|
||||
// Navigation 3 (JetBrains fork — multiplatform)
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||
implementation(libs.jetbrains.lifecycle.runtime.compose)
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ 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 +62,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
|
||||
@@ -80,30 +78,12 @@ 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.
|
||||
*/
|
||||
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 +125,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 +132,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()
|
||||
}
|
||||
|
||||
@@ -185,7 +161,6 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
notificationManager.notifications.collect { notification ->
|
||||
Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
|
||||
trayState.sendNotification(notification)
|
||||
}
|
||||
}
|
||||
@@ -223,25 +198,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 +214,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,8 +250,6 @@ 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"
|
||||
ImageLoader.Builder(context)
|
||||
@@ -316,12 +265,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(uiViewModel) }
|
||||
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
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
|
||||
@@ -34,26 +33,29 @@ import org.meshtastic.desktop.navigation.desktopNavGraph
|
||||
|
||||
/** Desktop main screen — uses shared navigation components. */
|
||||
@Composable
|
||||
fun DesktopMainScreen(uiViewModel: UIViewModel) {
|
||||
val backStack =
|
||||
androidx.navigation3.runtime.rememberNavBackStack(
|
||||
MeshtasticNavSavedStateConfig,
|
||||
NodesRoutes.NodesGraph as NavKey,
|
||||
)
|
||||
|
||||
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),
|
||||
) {
|
||||
MeshtasticNavigationSuite(
|
||||
backStack = backStack,
|
||||
multiBackstack = multiBackstack,
|
||||
uiViewModel = uiViewModel,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
|
||||
MeshtasticNavDisplay(backStack = backStack, entryProvider = provider, modifier = Modifier.fillMaxSize())
|
||||
MeshtasticNavDisplay(
|
||||
multiBackstack = multiBackstack,
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
|
||||
}
|
||||
|
||||
entry<NodeDetailRoutes.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
|
||||
val metricsViewModel =
|
||||
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
|
||||
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
|
||||
metricsViewModel.setNodeId(args.destNum)
|
||||
|
||||
TracerouteLogScreen(
|
||||
@@ -186,7 +185,7 @@ private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailS
|
||||
) {
|
||||
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() }
|
||||
|
||||
Reference in New Issue
Block a user