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:
James Rich
2026-03-26 22:16:44 -05:00
parent 39333b3bea
commit 48ac9a9019
12 changed files with 212 additions and 168 deletions

View File

@@ -219,6 +219,7 @@ dependencies {
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network)
implementation(projects.core.nfc)
implementation(projects.core.prefs)

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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