Refactor nav3 architecture and enhance adaptive layouts (#4944)

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

View file

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

View file

@ -19,16 +19,38 @@ package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavKey
/**
* Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the
* root destination.
* Replaces the last entry in the back stack with the given route. If the back stack is empty, it simply adds the route.
*/
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
fun MutableList<NavKey>.replaceLast(route: NavKey) {
if (isNotEmpty()) {
this[0] = route
while (size > 1) {
removeAt(lastIndex)
if (this[lastIndex] != route) {
this[lastIndex] = route
}
} else {
add(route)
}
}
/**
* Replaces the entire back stack with the given routes in a way that minimizes structural changes and prevents the back
* stack from temporarily becoming empty.
*/
fun MutableList<NavKey>.replaceAll(routes: List<NavKey>) {
if (routes.isEmpty()) {
clear()
return
}
for (i in routes.indices) {
if (i < size) {
// Only mutate if the route actually changed, protecting Nav3's internal state matching.
if (this[i] != routes[i]) {
this[i] = routes[i]
}
} else {
add(routes[i])
}
}
while (size > routes.size) {
removeAt(lastIndex)
}
}

View file

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

View file

@ -54,8 +54,12 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
}
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }

View file

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

View file

@ -16,13 +16,10 @@
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.ui.viewmodel.UIViewModel
@ -34,22 +31,21 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
*/
@Composable
fun MeshtasticAppShell(
backStack: NavBackStack<NavKey>,
multiBackstack: MultiBackstack,
uiViewModel: UIViewModel,
hostModifier: Modifier = Modifier.padding(bottom = 16.dp),
hostModifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
uiViewModel.navigationDeepLink.collect { navKeys -> multiBackstack.handleDeepLink(navKeys) }
}
MeshtasticCommonAppSetup(
uiViewModel = uiViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
multiBackstack.activeBackStack.add(
NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid),
)
},
)

View file

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

View file

@ -22,27 +22,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -51,16 +46,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.window.core.layout.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@ -70,13 +62,15 @@ import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and
* desktop targets.
* Shared adaptive navigation shell using [NavigationSuiteScaffold].
*
* This implementation uses the [MultiBackstack] state holder to manage independent histories for each tab, aligning
* with Navigation 3 best practices for state preservation during tab switching.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MeshtasticNavigationSuite(
backStack: NavBackStack<NavKey>,
multiBackstack: MultiBackstack,
uiViewModel: UIViewModel,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
@ -86,60 +80,69 @@ fun MeshtasticNavigationSuite(
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val currentKey = backStack.lastOrNull()
val rootKey = backStack.firstOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
val onNavigate = { destination: TopLevelDestination ->
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
}
val currentTabRoute = multiBackstack.currentTabRoute
val topLevelDestination = TopLevelDestination.fromNavKey(currentTabRoute)
if (isCompact) {
Scaffold(
modifier = modifier,
bottomBar = {
MeshtasticNavigationBar(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType()
val showLabels = layoutType == NavigationSuiteType.NavigationRail
NavigationSuiteScaffold(
modifier = modifier,
layoutType = layoutType,
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
item(
selected = isSelected,
onClick = { handleNavigation(destination, topLevelDestination, multiBackstack, uiViewModel) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = isSelected,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label =
if (showLabels) {
{ Text(stringResource(destination.label)) }
} else {
null
},
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() }
}
} else {
Row(modifier = modifier.fillMaxSize()) {
MeshtasticNavigationRail(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
)
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
}
}
},
) {
Row { content() }
}
}
/**
* Caps [NavigationSuiteType] so that expanded/extra-large widths still use a NavigationRail instead of promoting to a
* permanent NavigationDrawer.
*/
private fun NavigationSuiteType.coerceNavigationType(): NavigationSuiteType = when (this) {
NavigationSuiteType.NavigationDrawer -> NavigationSuiteType.NavigationRail
else -> this
}
private fun handleNavigation(
destination: TopLevelDestination,
topLevelDestination: TopLevelDestination?,
currentKey: NavKey?,
backStack: NavBackStack<NavKey>,
multiBackstack: MultiBackstack,
uiViewModel: UIViewModel,
) {
val isRepress = destination == topLevelDestination
if (isRepress) {
val currentKey = multiBackstack.activeBackStack.lastOrNull()
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
multiBackstack.navigateTopLevel(destination.route)
} else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
@ -148,78 +151,19 @@ private fun handleNavigation(
val onConversationsList =
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
multiBackstack.navigateTopLevel(destination.route)
} else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
}
else -> {
if (currentKey != destination.route) {
backStack.navigateTopLevel(destination.route)
multiBackstack.navigateTopLevel(destination.route)
}
}
}
} else {
backStack.navigateTopLevel(destination.route)
}
}
@Composable
private fun MeshtasticNavigationBar(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationBar {
TopLevelDestination.entries.forEach { destination ->
NavigationBarItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
)
}
}
}
@Composable
private fun MeshtasticNavigationRail(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label = { Text(stringResource(destination.label)) },
)
}
multiBackstack.navigateTopLevel(destination.route)
}
}