diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 752b2be0b..cb0b23f5e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,7 @@ dependencies { implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.navigation) + implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(projects.core.network) implementation(projects.core.nfc) implementation(projects.core.prefs) diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 3c3df027f..ad1e382ce 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -173,8 +173,7 @@ class MainActivity : ComponentActivity() { }, org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides { destNum, requestId, logUuid, onNavigateUp -> - val metricsViewModel = - koinViewModel(key = "metrics-$destNum") { + val metricsViewModel = koinViewModel { org.koin.core.parameter.parametersOf(destNum) } metricsViewModel.setNodeId(destNum) diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 1eb87a65d..1002d01fd 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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 { contactsGraph(backStack, viewModel.scrollToTopEventFlow) @@ -74,7 +78,7 @@ fun MainScreen() { firmwareGraph(backStack) } MeshtasticNavDisplay( - backStack = backStack, + multiBackstack = multiBackstack, entryProvider = provider, modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(), ) diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt new file mode 100644 index 000000000..51faeff50 --- /dev/null +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt @@ -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 . + */ +package org.meshtastic.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack + +/** + * Manages independent backstacks for multiple tabs. + */ +class MultiBackstack(val startTab: NavKey) { + var backStacks: Map> = emptyMap() + + var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab) + private set + + val activeBackStack: NavBackStack + get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found") + + /** + * Switches to a new top-level tab route. + */ + fun navigateTopLevel(route: NavKey) { + val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route + + if (currentTabRoute == rootKey) { + // Repressing the same tab resets its stack to just the root + activeBackStack.replaceAll(listOf(rootKey)) + } else { + // Switching to a different tab + currentTabRoute = rootKey + } + } + + /** + * Handles back navigation according to the "exit through home" pattern. + */ + fun goBack() { + val currentStack = activeBackStack + if (currentStack.size > 1) { + currentStack.removeLastOrNull() + return + } + + // If we're at the root of a non-start tab, switch back to the start tab + if (currentTabRoute != startTab) { + currentTabRoute = startTab + } + } + + /** + * Sets the active tab and replaces its stack with the provided route path. + */ + fun handleDeepLink(navKeys: List) { + val rootKey = navKeys.firstOrNull() ?: return + val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey + currentTabRoute = topLevel + val stack = backStacks[topLevel] ?: return + stack.replaceAll(navKeys) + } +} + +/** + * Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. + */ +@Composable +fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack { + val stacks = mutableMapOf>() + + TopLevelDestination.entries.forEach { dest -> + key(dest.route) { + stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) + } + } + + val multiBackstack = remember { MultiBackstack(initialTab) } + multiBackstack.backStacks = stacks + + return multiBackstack +} diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt index a70a377d0..fa597c65f 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt @@ -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.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. */ diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt index 31184d453..42aa57686 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -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, + 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) + ) }, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt index 2339cacc0..21f680901 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt @@ -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, + 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, + backStack: NavBackStack, + onBack: (() -> Unit)? = null, entryProvider: (key: NavKey) -> NavEntry, modifier: Modifier = Modifier, ) { @@ -111,11 +111,23 @@ fun MeshtasticNavDisplay( ) }, ) + + val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator() + val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator() + + 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>.() -> ContentTransform = { ContentTransform( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt index e25e28f84..c998b6e8a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt @@ -22,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, + 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, + 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) } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index da579bbaf..a5de1227d 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 3f30f9b30..ad53c06cc 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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) = application(exitProcessOnExit = false) { } } - // Start the mesh service processing chain (desktop equivalent of Android's MeshService) val meshServiceController = remember { koinApp.koin.get() } DisposableEffect(Unit) { meshServiceController.start() @@ -153,18 +132,15 @@ fun main(args: Array) = application(exitProcessOnExit = false) { } val uiPrefs = remember { koinApp.koin.get() } - val themePref by uiPrefs.theme.collectAsState(initial = -1) // -1 is SYSTEM usually + val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") - // Apply persisted locale to the JVM default synchronously so CMP Resources sees - // it during the current composition frame. Empty string falls back to the startup - // system locale captured before any app-specific override was applied. Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) val isDarkTheme = when (themePref) { - 1 -> false // MODE_NIGHT_NO - 2 -> true // MODE_NIGHT_YES + 1 -> false + 2 -> true else -> isSystemInDarkTheme() } @@ -185,7 +161,6 @@ fun main(args: Array) = 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) = 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) = 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) = 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) = 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) } } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 0a8082226..39b8c42c5 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -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 { desktopNavGraph(backStack, uiViewModel) } - MeshtasticNavDisplay(backStack = backStack, entryProvider = provider, modifier = Modifier.fillMaxSize()) + MeshtasticNavDisplay( + multiBackstack = multiBackstack, + entryProvider = provider, + modifier = Modifier.fillMaxSize() + ) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index eb3018865..276e2892e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -129,8 +129,7 @@ fun EntryProviderScope.nodeDetailGraph( } entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> - val metricsViewModel = - koinViewModel(key = "metrics-${args.destNum}") { parametersOf(args.destNum) } + val metricsViewModel = koinViewModel { parametersOf(args.destNum) } metricsViewModel.setNodeId(args.destNum) TracerouteLogScreen( @@ -186,7 +185,7 @@ private inline fun EntryProviderScope.addNodeDetailS ) { entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val destNum = getDestNum(args) - val metricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) } + val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }