mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor nav3 architecture and enhance adaptive layouts (#4944)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
3feec759a1
commit
f2d09ff79d
29 changed files with 740 additions and 617 deletions
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
|
||||
/** Manages independent backstacks for multiple tabs. */
|
||||
class MultiBackstack(val startTab: NavKey) {
|
||||
var backStacks: Map<NavKey, NavBackStack<NavKey>> = emptyMap()
|
||||
|
||||
var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab)
|
||||
private set
|
||||
|
||||
val activeBackStack: NavBackStack<NavKey>
|
||||
get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found")
|
||||
|
||||
/** Switches to a new top-level tab route. */
|
||||
fun navigateTopLevel(route: NavKey) {
|
||||
val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route
|
||||
|
||||
if (currentTabRoute == rootKey) {
|
||||
// Repressing the same tab resets its stack to just the root
|
||||
activeBackStack.replaceAll(listOf(rootKey))
|
||||
} else {
|
||||
// Switching to a different tab
|
||||
currentTabRoute = rootKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles back navigation according to the "exit through home" pattern. */
|
||||
fun goBack() {
|
||||
val currentStack = activeBackStack
|
||||
if (currentStack.size > 1) {
|
||||
currentStack.removeLastOrNull()
|
||||
return
|
||||
}
|
||||
|
||||
// If we're at the root of a non-start tab, switch back to the start tab
|
||||
if (currentTabRoute != startTab) {
|
||||
currentTabRoute = startTab
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the active tab and replaces its stack with the provided route path. */
|
||||
fun handleDeepLink(navKeys: List<NavKey>) {
|
||||
val rootKey = navKeys.firstOrNull() ?: return
|
||||
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
|
||||
currentTabRoute = topLevel
|
||||
val stack = backStacks[topLevel] ?: return
|
||||
stack.replaceAll(navKeys)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */
|
||||
@Composable
|
||||
fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack {
|
||||
val stacks = mutableMapOf<NavKey, NavBackStack<NavKey>>()
|
||||
|
||||
TopLevelDestination.entries.forEach { dest ->
|
||||
key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) }
|
||||
}
|
||||
|
||||
val multiBackstack = remember { MultiBackstack(initialTab) }
|
||||
multiBackstack.backStacks = stacks
|
||||
|
||||
return multiBackstack
|
||||
}
|
||||
|
|
@ -19,16 +19,38 @@ package org.meshtastic.core.navigation
|
|||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
/**
|
||||
* Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the
|
||||
* root destination.
|
||||
* Replaces the last entry in the back stack with the given route. If the back stack is empty, it simply adds the route.
|
||||
*/
|
||||
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
|
||||
fun MutableList<NavKey>.replaceLast(route: NavKey) {
|
||||
if (isNotEmpty()) {
|
||||
this[0] = route
|
||||
while (size > 1) {
|
||||
removeAt(lastIndex)
|
||||
if (this[lastIndex] != route) {
|
||||
this[lastIndex] = route
|
||||
}
|
||||
} else {
|
||||
add(route)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the entire back stack with the given routes in a way that minimizes structural changes and prevents the back
|
||||
* stack from temporarily becoming empty.
|
||||
*/
|
||||
fun MutableList<NavKey>.replaceAll(routes: List<NavKey>) {
|
||||
if (routes.isEmpty()) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
for (i in routes.indices) {
|
||||
if (i < size) {
|
||||
// Only mutate if the route actually changed, protecting Nav3's internal state matching.
|
||||
if (this[i] != routes[i]) {
|
||||
this[i] = routes[i]
|
||||
}
|
||||
} else {
|
||||
add(routes[i])
|
||||
}
|
||||
}
|
||||
while (size > routes.size) {
|
||||
removeAt(lastIndex)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MultiBackstackTest {
|
||||
|
||||
@Test
|
||||
fun `navigateTopLevel to different tab preserves previous tab stack and activates new tab stack`() {
|
||||
val startTab = TopLevelDestination.Nodes.route
|
||||
val multiBackstack = MultiBackstack(startTab)
|
||||
|
||||
val nodesStack =
|
||||
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
|
||||
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
|
||||
|
||||
multiBackstack.backStacks =
|
||||
mapOf(TopLevelDestination.Nodes.route to nodesStack, TopLevelDestination.Map.route to mapStack)
|
||||
|
||||
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
|
||||
assertEquals(2, multiBackstack.activeBackStack.size)
|
||||
|
||||
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
|
||||
|
||||
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
|
||||
assertEquals(1, multiBackstack.activeBackStack.size)
|
||||
assertEquals(2, nodesStack.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateTopLevel to same tab resets stack to root`() {
|
||||
val startTab = TopLevelDestination.Nodes.route
|
||||
val multiBackstack = MultiBackstack(startTab)
|
||||
|
||||
val nodesStack =
|
||||
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
|
||||
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
|
||||
|
||||
assertEquals(2, multiBackstack.activeBackStack.size)
|
||||
|
||||
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
|
||||
|
||||
assertEquals(1, multiBackstack.activeBackStack.size)
|
||||
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goBack pops current stack if size is greater than 1`() {
|
||||
val startTab = TopLevelDestination.Nodes.route
|
||||
val multiBackstack = MultiBackstack(startTab)
|
||||
|
||||
val nodesStack =
|
||||
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
|
||||
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
|
||||
|
||||
multiBackstack.goBack()
|
||||
|
||||
assertEquals(1, multiBackstack.activeBackStack.size)
|
||||
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goBack on root of non-start tab returns to start tab`() {
|
||||
val startTab = TopLevelDestination.Connections.route
|
||||
val multiBackstack = MultiBackstack(startTab)
|
||||
|
||||
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
|
||||
val connectionsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
|
||||
|
||||
multiBackstack.backStacks =
|
||||
mapOf(TopLevelDestination.Map.route to mapStack, TopLevelDestination.Connections.route to connectionsStack)
|
||||
|
||||
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
|
||||
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
|
||||
|
||||
multiBackstack.goBack()
|
||||
|
||||
assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleDeepLink sets target tab and populates stack`() {
|
||||
val startTab = TopLevelDestination.Nodes.route
|
||||
val multiBackstack = MultiBackstack(startTab)
|
||||
|
||||
val settingsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Settings.route)) }
|
||||
multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack)
|
||||
|
||||
val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoutes.About)
|
||||
multiBackstack.handleDeepLink(deepLinkPath)
|
||||
|
||||
assertEquals(TopLevelDestination.Settings.route, multiBackstack.currentTabRoute)
|
||||
assertEquals(2, multiBackstack.activeBackStack.size)
|
||||
assertEquals(SettingsRoutes.About, multiBackstack.activeBackStack.last())
|
||||
}
|
||||
}
|
||||
|
|
@ -54,8 +54,12 @@ kotlin {
|
|||
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
|
||||
implementation(libs.jetbrains.navigationevent.compose)
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||
}
|
||||
|
||||
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.navigationevent.NavigationEventInfo
|
||||
import androidx.navigationevent.compose.NavigationBackHandler
|
||||
import androidx.navigationevent.compose.rememberNavigationEventState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun <T> AdaptiveListDetailScaffold(
|
||||
navigator: ThreePaneScaffoldNavigator<T>,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
onBackToGraph: () -> Unit,
|
||||
onTabPressedEvent: (ScrollToTopEvent) -> Boolean,
|
||||
initialKey: T? = null,
|
||||
listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit,
|
||||
detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit,
|
||||
emptyDetailPane: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
|
||||
|
||||
val handleBack: () -> Unit = {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
scope.launch { navigator.navigateBack(backNavigationBehavior) }
|
||||
} else {
|
||||
onBackToGraph()
|
||||
}
|
||||
}
|
||||
|
||||
val navState = rememberNavigationEventState(NavigationEventInfo.None)
|
||||
NavigationBackHandler(
|
||||
state = navState,
|
||||
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
|
||||
onBackCancelled = {},
|
||||
onBackCompleted = { handleBack() },
|
||||
)
|
||||
|
||||
LaunchedEffect(initialKey) {
|
||||
if (initialKey != null) {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents.collect { event ->
|
||||
if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
navigator.navigateBack(backNavigationBehavior)
|
||||
} else {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
val focusManager = LocalFocusManager.current
|
||||
// Prevent TextFields from auto-focusing when pane animates in
|
||||
LaunchedEffect(Unit) { focusManager.clearFocus() }
|
||||
|
||||
listPane(
|
||||
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List,
|
||||
navigator.currentDestination?.contentKey,
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
navigator.currentDestination?.contentKey?.let { contentKey ->
|
||||
key(contentKey) {
|
||||
LaunchedEffect(contentKey) { focusManager.clearFocus() }
|
||||
detailPane(contentKey, handleBack)
|
||||
}
|
||||
} ?: emptyDetailPane()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -16,13 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.meshtastic.core.navigation.MultiBackstack
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
|
|
@ -34,22 +31,21 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
|
|||
*/
|
||||
@Composable
|
||||
fun MeshtasticAppShell(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
multiBackstack: MultiBackstack,
|
||||
uiViewModel: UIViewModel,
|
||||
hostModifier: Modifier = Modifier.padding(bottom = 16.dp),
|
||||
hostModifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(uiViewModel) {
|
||||
uiViewModel.navigationDeepLink.collect { navKeys ->
|
||||
backStack.clear()
|
||||
backStack.addAll(navKeys)
|
||||
}
|
||||
uiViewModel.navigationDeepLink.collect { navKeys -> multiBackstack.handleDeepLink(navKeys) }
|
||||
}
|
||||
|
||||
MeshtasticCommonAppSetup(
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
|
||||
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
|
||||
multiBackstack.activeBackStack.add(
|
||||
NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.VerticalDragHandle
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
|
||||
import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.scene.DialogSceneStrategy
|
||||
import androidx.navigation3.scene.Scene
|
||||
import androidx.navigation3.scene.SinglePaneSceneStrategy
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import org.meshtastic.core.navigation.MultiBackstack
|
||||
|
||||
/** Duration in milliseconds for the shared crossfade transition between navigation scenes. */
|
||||
private const val TRANSITION_DURATION_MS = 350
|
||||
|
||||
/**
|
||||
* Shared [NavDisplay] wrapper that configures the standard Meshtastic entry decorators, scene strategies, and
|
||||
* transition animations for all platform hosts.
|
||||
*
|
||||
* This version supports multiple backstacks by accepting a [MultiBackstack] state holder.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticNavDisplay(
|
||||
multiBackstack: MultiBackstack,
|
||||
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backStack = multiBackstack.activeBackStack
|
||||
MeshtasticNavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { multiBackstack.goBack() },
|
||||
entryProvider = entryProvider,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared [NavDisplay] wrapper for a single backstack. */
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun MeshtasticNavDisplay(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
onBack: (() -> Unit)? = null,
|
||||
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listDetailSceneStrategy =
|
||||
rememberListDetailSceneStrategy<NavKey>(
|
||||
paneExpansionState = rememberPaneExpansionState(),
|
||||
paneExpansionDragHandle = { state ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
VerticalDragHandle(
|
||||
modifier =
|
||||
Modifier.paneExpansionDraggable(
|
||||
state = state,
|
||||
minTouchTargetSize = 48.dp,
|
||||
interactionSource = interactionSource,
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
},
|
||||
)
|
||||
val supportingPaneSceneStrategy =
|
||||
rememberSupportingPaneSceneStrategy<NavKey>(
|
||||
paneExpansionState = rememberPaneExpansionState(),
|
||||
paneExpansionDragHandle = { state ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
VerticalDragHandle(
|
||||
modifier =
|
||||
Modifier.paneExpansionDraggable(
|
||||
state = state,
|
||||
minTouchTargetSize = 48.dp,
|
||||
interactionSource = interactionSource,
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<NavKey>()
|
||||
val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator<NavKey>()
|
||||
|
||||
val activeDecorators =
|
||||
remember(backStack, saveableDecorator, vmStoreDecorator) { listOf(saveableDecorator, vmStoreDecorator) }
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = entryProvider,
|
||||
entryDecorators = activeDecorators,
|
||||
onBack =
|
||||
onBack
|
||||
?: {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
},
|
||||
sceneStrategies =
|
||||
listOf(
|
||||
DialogSceneStrategy(),
|
||||
listDetailSceneStrategy,
|
||||
supportingPaneSceneStrategy,
|
||||
SinglePaneSceneStrategy(),
|
||||
),
|
||||
transitionSpec = meshtasticTransitionSpec(),
|
||||
popTransitionSpec = meshtasticTransitionSpec(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared crossfade [ContentTransform] used for both forward and pop navigation. */
|
||||
private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
|
||||
ContentTransform(
|
||||
fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)),
|
||||
fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)),
|
||||
)
|
||||
}
|
||||
|
|
@ -22,27 +22,22 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -51,16 +46,13 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.MultiBackstack
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.navigateTopLevel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
|
|
@ -70,13 +62,15 @@ import org.meshtastic.core.ui.navigation.icon
|
|||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and
|
||||
* desktop targets.
|
||||
* Shared adaptive navigation shell using [NavigationSuiteScaffold].
|
||||
*
|
||||
* This implementation uses the [MultiBackstack] state holder to manage independent histories for each tab, aligning
|
||||
* with Navigation 3 best practices for state preservation during tab switching.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeshtasticNavigationSuite(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
multiBackstack: MultiBackstack,
|
||||
uiViewModel: UIViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
|
|
@ -86,60 +80,69 @@ fun MeshtasticNavigationSuite(
|
|||
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
|
||||
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val rootKey = backStack.firstOrNull()
|
||||
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
|
||||
|
||||
val onNavigate = { destination: TopLevelDestination ->
|
||||
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
|
||||
}
|
||||
val currentTabRoute = multiBackstack.currentTabRoute
|
||||
val topLevelDestination = TopLevelDestination.fromNavKey(currentTabRoute)
|
||||
|
||||
if (isCompact) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
bottomBar = {
|
||||
MeshtasticNavigationBar(
|
||||
topLevelDestination = topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigate = onNavigate,
|
||||
val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType()
|
||||
val showLabels = layoutType == NavigationSuiteType.NavigationRail
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
modifier = modifier,
|
||||
layoutType = layoutType,
|
||||
navigationSuiteItems = {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
val isSelected = destination == topLevelDestination
|
||||
item(
|
||||
selected = isSelected,
|
||||
onClick = { handleNavigation(destination, topLevelDestination, multiBackstack, uiViewModel) },
|
||||
icon = {
|
||||
NavigationIconContent(
|
||||
destination = destination,
|
||||
isSelected = isSelected,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
)
|
||||
},
|
||||
label =
|
||||
if (showLabels) {
|
||||
{ Text(stringResource(destination.label)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() }
|
||||
}
|
||||
} else {
|
||||
Row(modifier = modifier.fillMaxSize()) {
|
||||
MeshtasticNavigationRail(
|
||||
topLevelDestination = topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigate = onNavigate,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps [NavigationSuiteType] so that expanded/extra-large widths still use a NavigationRail instead of promoting to a
|
||||
* permanent NavigationDrawer.
|
||||
*/
|
||||
private fun NavigationSuiteType.coerceNavigationType(): NavigationSuiteType = when (this) {
|
||||
NavigationSuiteType.NavigationDrawer -> NavigationSuiteType.NavigationRail
|
||||
else -> this
|
||||
}
|
||||
|
||||
private fun handleNavigation(
|
||||
destination: TopLevelDestination,
|
||||
topLevelDestination: TopLevelDestination?,
|
||||
currentKey: NavKey?,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
multiBackstack: MultiBackstack,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
val isRepress = destination == topLevelDestination
|
||||
if (isRepress) {
|
||||
val currentKey = multiBackstack.activeBackStack.lastOrNull()
|
||||
when (destination) {
|
||||
TopLevelDestination.Nodes -> {
|
||||
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
|
||||
if (!onNodesList) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
multiBackstack.navigateTopLevel(destination.route)
|
||||
} else {
|
||||
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
|
||||
}
|
||||
|
|
@ -148,78 +151,19 @@ private fun handleNavigation(
|
|||
val onConversationsList =
|
||||
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
|
||||
if (!onConversationsList) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
multiBackstack.navigateTopLevel(destination.route)
|
||||
} else {
|
||||
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (currentKey != destination.route) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
multiBackstack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeshtasticNavigationBar(
|
||||
topLevelDestination: TopLevelDestination?,
|
||||
connectionState: ConnectionState,
|
||||
unreadMessageCount: Int,
|
||||
selectedDevice: String?,
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigate: (TopLevelDestination) -> Unit,
|
||||
) {
|
||||
NavigationBar {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = destination == topLevelDestination,
|
||||
onClick = { onNavigate(destination) },
|
||||
icon = {
|
||||
NavigationIconContent(
|
||||
destination = destination,
|
||||
isSelected = destination == topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeshtasticNavigationRail(
|
||||
topLevelDestination: TopLevelDestination?,
|
||||
connectionState: ConnectionState,
|
||||
unreadMessageCount: Int,
|
||||
selectedDevice: String?,
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigate: (TopLevelDestination) -> Unit,
|
||||
) {
|
||||
NavigationRail {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = destination == topLevelDestination,
|
||||
onClick = { onNavigate(destination) },
|
||||
icon = {
|
||||
NavigationIconContent(
|
||||
destination = destination,
|
||||
isSelected = destination == topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
}
|
||||
multiBackstack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue