Switch from navigation 2 to navigation 3
This commit is contained in:
@@ -90,7 +90,7 @@ kotlin {
|
||||
implementation(libs.kotlinx.serializationJson)
|
||||
implementation(libs.multiplatform.settings)
|
||||
implementation(libs.lokksmith.compose)
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(libs.navigation3.ui)
|
||||
implementation(libs.compose.unstyled)
|
||||
implementation(libs.compose.icons.lucide)
|
||||
implementation(libs.liquid)
|
||||
|
||||
@@ -12,8 +12,9 @@ import org.koin.plugin.module.dsl.viewModel
|
||||
val shellModule =
|
||||
module {
|
||||
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
|
||||
// Active-tab tracking lives in NavController (currentBackStackEntryAsState),
|
||||
// not in a shell-level VM, so there is no ShellViewModel to register.
|
||||
// Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
|
||||
// owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
|
||||
// to register.
|
||||
viewModel<PlannerViewModel>()
|
||||
viewModel<RecipesViewModel>()
|
||||
viewModel<PantryViewModel>()
|
||||
|
||||
@@ -16,8 +16,11 @@ import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||
/**
|
||||
* The 4 bottom-bar destinations in left→right order per CONTEXT D-03:
|
||||
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
|
||||
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing
|
||||
* order, which research confirmed is non-binding.
|
||||
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal
|
||||
* listing order, which research confirmed is non-binding.
|
||||
*
|
||||
* Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home])
|
||||
* so the shell's [TabNavigator] knows where each tab's back stack starts.
|
||||
*
|
||||
* Per-tab contextual chrome (search button on Recipes / Pantry, future filter
|
||||
* buttons elsewhere) is owned by each screen via the slot pattern in
|
||||
@@ -25,27 +28,27 @@ import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||
* intentionally minimal: route + label + icon, nothing about feature affordances.
|
||||
*/
|
||||
enum class BottomBarDestination(
|
||||
val graphRoute: Any,
|
||||
val startDestination: Screen,
|
||||
val labelRes: StringResource,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Planner(
|
||||
graphRoute = PlannerGraph,
|
||||
startDestination = Screen.Planner.Home,
|
||||
labelRes = Res.string.shell_tab_planner,
|
||||
icon = Lucide.CalendarDays,
|
||||
),
|
||||
Recipes(
|
||||
graphRoute = RecipesGraph,
|
||||
startDestination = Screen.Recipes.Home,
|
||||
labelRes = Res.string.shell_tab_recipes,
|
||||
icon = Lucide.BookOpenText,
|
||||
),
|
||||
Pantry(
|
||||
graphRoute = PantryGraph,
|
||||
startDestination = Screen.Pantry.Home,
|
||||
labelRes = Res.string.shell_tab_pantry,
|
||||
icon = Lucide.Package,
|
||||
),
|
||||
Shopping(
|
||||
graphRoute = ShoppingGraph,
|
||||
startDestination = Screen.Shopping.Home,
|
||||
labelRes = Res.string.shell_tab_shopping,
|
||||
icon = Lucide.ShoppingCart,
|
||||
),
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
|
||||
/**
|
||||
* Multi-back-stack tab navigation per UI-03 + RESEARCH § Pattern 1 (lines 304-339).
|
||||
*
|
||||
* Applies the canonical four-flag incantation:
|
||||
* - `popUpTo(graph.findStartDestination().id) { saveState = true }` — saves the
|
||||
* current tab's stack so re-selecting the tab later restores it.
|
||||
* - `launchSingleTop = true` — selecting an already-active tab does NOT push a
|
||||
* duplicate onto the back stack.
|
||||
* - `restoreState = true` — when the destination tab is re-selected, restore its
|
||||
* saved state instead of recreating it. CRITICAL: without this flag, ViewModels
|
||||
* are re-created on every reselection (RESEARCH § Pitfall B).
|
||||
*
|
||||
* @param graphRoute the @Serializable graph route (e.g. PlannerGraph, RecipesGraph)
|
||||
*/
|
||||
fun NavHostController.navigateToTab(graphRoute: Any) {
|
||||
navigate(graphRoute) {
|
||||
popUpTo(graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
/**
|
||||
* Nav 3 host for the 4-tab shell. Renders **one** [NavDisplay] for the active
|
||||
* tab, swapped via [AnimatedContent] when [TabNavigator.activeTab] changes.
|
||||
*
|
||||
* ## Why one display per active tab, not one shared display
|
||||
* Nav 3's [NavDisplay] takes a single back stack. A shell with parallel tab
|
||||
* stacks therefore needs either:
|
||||
* - one display that re-keys when the tab changes (this implementation), or
|
||||
* - four always-composed displays stacked z-order, alpha-toggled by tab.
|
||||
*
|
||||
* The re-keyed approach matches the reference [To-Do-CMP](https://github.com/stevdza-san/To-Do-CMP)
|
||||
* structure 1:1, gives a clean 180 ms cross-fade between tabs, and avoids the
|
||||
* predictive-back-handler arbitration headache that comes with multiple live
|
||||
* NavDisplays competing for the gesture (see `NavDisplay.kt` source —
|
||||
* `NavigationBackHandler` is enabled when `previousEntries.isNotEmpty()`, which
|
||||
* is per-display and would mis-fire if multiple displays were alive at once).
|
||||
*
|
||||
* ## ViewModel lifetime
|
||||
* No `rememberViewModelStoreNavEntryDecorator` is installed, so `koinViewModel`
|
||||
* inside `entry<…>` resolves through the **host** `ViewModelStoreOwner`
|
||||
* (Android: the Activity; iOS: the root Compose owner). That keeps tab VMs
|
||||
* alive across tab switches even though the per-tab [NavDisplay] is unmounted
|
||||
* during cross-fade — matches the previous Nav-2 multi-back-stack behaviour
|
||||
* (`saveState=true`/`restoreState=true`).
|
||||
*
|
||||
* Phase 5+ introduces detail screens with their own VM scopes; at that point
|
||||
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
|
||||
* specifically (passed via `entryDecorators = listOf(...)`).
|
||||
*/
|
||||
@Composable
|
||||
fun RootNavDisplay(
|
||||
navigator: TabNavigator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = navigator.activeTab,
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 180)) togetherWith
|
||||
fadeOut(tween(durationMillis = 180))
|
||||
},
|
||||
label = "RootNavDisplay tab cross-fade",
|
||||
) { tab ->
|
||||
NavDisplay(
|
||||
backStack = navigator.backStackFor(tab),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = { navigator.goBack(tab) },
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Planner.Home> {
|
||||
val vm: PlannerViewModel = koinViewModel()
|
||||
PlannerScreen(viewModel = vm)
|
||||
}
|
||||
entry<Screen.Recipes.Home> {
|
||||
val vm: RecipesViewModel = koinViewModel()
|
||||
val searchVm: RecipesSearchViewModel = koinViewModel()
|
||||
RecipesScreen(viewModel = vm, searchViewModel = searchVm)
|
||||
}
|
||||
entry<Screen.Pantry.Home> {
|
||||
val vm: PantryViewModel = koinViewModel()
|
||||
val searchVm: PantrySearchViewModel = koinViewModel()
|
||||
PantryScreen(viewModel = vm, searchViewModel = searchVm)
|
||||
}
|
||||
entry<Screen.Shopping.Home> {
|
||||
val vm: ShoppingViewModel = koinViewModel()
|
||||
ShoppingScreen(viewModel = vm)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navigation
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
/**
|
||||
* Root of the app shell's navigation. Hosts ONE root [NavHost] containing four
|
||||
* [navigation] sub-graphs (one per tab) so each tab preserves its own back stack
|
||||
* independently across tab switches (RESEARCH § Pattern 1; UI-03).
|
||||
*
|
||||
* Default start destination: [PlannerGraph] per CONTEXT D-03.
|
||||
*
|
||||
* Per-tab ViewModel scoping: each composable<*Home> block retrieves the parent
|
||||
* graph's [androidx.navigation.NavBackStackEntry] via
|
||||
* `navController.getBackStackEntry(*Graph)` and passes it as `viewModelStoreOwner`
|
||||
* to `koinViewModel(...)`. This makes per-tab VMs survive within the graph
|
||||
* (RESEARCH § Pattern 2) — Phase 5 detail screens inherit cleanly.
|
||||
*/
|
||||
@Composable
|
||||
fun RootNavHost(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = PlannerGraph,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
// ---- Planner graph (default landing — D-03) ----
|
||||
navigation<PlannerGraph>(startDestination = PlannerHome) {
|
||||
composable<PlannerHome> { entry ->
|
||||
val parent =
|
||||
remember(entry) {
|
||||
navController.getBackStackEntry(PlannerGraph)
|
||||
}
|
||||
val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
|
||||
PlannerScreen(viewModel = vm)
|
||||
}
|
||||
// future: composable<PlannerDetail>{ ... }
|
||||
}
|
||||
|
||||
// ---- Recipes graph ----
|
||||
navigation<RecipesGraph>(startDestination = RecipesHome) {
|
||||
composable<RecipesHome> { entry ->
|
||||
val parent =
|
||||
remember(entry) {
|
||||
navController.getBackStackEntry(RecipesGraph)
|
||||
}
|
||||
val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
|
||||
val searchVm: RecipesSearchViewModel = koinViewModel(viewModelStoreOwner = parent)
|
||||
RecipesScreen(viewModel = vm, searchViewModel = searchVm)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pantry graph ----
|
||||
navigation<PantryGraph>(startDestination = PantryHome) {
|
||||
composable<PantryHome> { entry ->
|
||||
val parent =
|
||||
remember(entry) {
|
||||
navController.getBackStackEntry(PantryGraph)
|
||||
}
|
||||
val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent)
|
||||
val searchVm: PantrySearchViewModel = koinViewModel(viewModelStoreOwner = parent)
|
||||
PantryScreen(viewModel = vm, searchViewModel = searchVm)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shopping graph ----
|
||||
navigation<ShoppingGraph>(startDestination = ShoppingHome) {
|
||||
composable<ShoppingHome> { entry ->
|
||||
val parent =
|
||||
remember(entry) {
|
||||
navController.getBackStackEntry(ShoppingGraph)
|
||||
}
|
||||
val vm: ShoppingViewModel = koinViewModel(viewModelStoreOwner = parent)
|
||||
ShoppingScreen(viewModel = vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Type-safe route definitions for the 4-tab app shell (CONTEXT D-03).
|
||||
* Each tab graph has a serializable route type and a home (start) destination.
|
||||
* Phase 5+ extends each graph with detail destinations (RESEARCH § Pattern 1).
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data object PlannerGraph
|
||||
|
||||
@Serializable
|
||||
data object PlannerHome
|
||||
|
||||
@Serializable
|
||||
data object RecipesGraph
|
||||
|
||||
@Serializable
|
||||
data object RecipesHome
|
||||
|
||||
@Serializable
|
||||
data object PantryGraph
|
||||
|
||||
@Serializable
|
||||
data object PantryHome
|
||||
|
||||
@Serializable
|
||||
data object ShoppingGraph
|
||||
|
||||
@Serializable
|
||||
data object ShoppingHome
|
||||
@@ -0,0 +1,35 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Type-safe Nav 3 destinations. Each leaf is a `@Serializable` `NavKey` so the
|
||||
* back stack can be persisted (Nav 3 uses kotlinx-serialization for restoration).
|
||||
*
|
||||
* Screens are grouped by tab so future detail destinations (Phase 5+) slot in
|
||||
* without polluting the top-level namespace — e.g. `Screen.Recipes.Detail(id)`.
|
||||
* The grouping is purely a code-organisation convenience; Nav 3 treats each
|
||||
* leaf as an independent NavKey regardless of nesting.
|
||||
*/
|
||||
sealed interface Screen : NavKey {
|
||||
sealed interface Planner : Screen {
|
||||
@Serializable
|
||||
data object Home : Planner
|
||||
}
|
||||
|
||||
sealed interface Recipes : Screen {
|
||||
@Serializable
|
||||
data object Home : Recipes
|
||||
}
|
||||
|
||||
sealed interface Pantry : Screen {
|
||||
@Serializable
|
||||
data object Home : Pantry
|
||||
}
|
||||
|
||||
sealed interface Shopping : Screen {
|
||||
@Serializable
|
||||
data object Home : Shopping
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
@Stable
|
||||
class TabNavigator(
|
||||
initialTab: BottomBarDestination = BottomBarDestination.Default,
|
||||
) {
|
||||
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
|
||||
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||
|
||||
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
|
||||
private set
|
||||
|
||||
val activeBackStack: SnapshotStateList<Screen>
|
||||
get() = backStacks.getValue(activeTab)
|
||||
|
||||
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
|
||||
backStacks.getValue(tab)
|
||||
|
||||
fun selectTab(tab: BottomBarDestination) {
|
||||
if (tab == activeTab) {
|
||||
popToRoot(tab)
|
||||
} else {
|
||||
activeTab = tab
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(screen: Screen) {
|
||||
activeBackStack.add(screen)
|
||||
}
|
||||
|
||||
fun goBack(tab: BottomBarDestination = activeTab) {
|
||||
val stack = backStacks.getValue(tab)
|
||||
if (stack.size > 1) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun popToRoot(tab: BottomBarDestination) {
|
||||
val stack = backStacks.getValue(tab)
|
||||
while (stack.size > 1) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,24 +20,14 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.PantryGraph
|
||||
import dev.ulfrx.recipe.navigation.PlannerGraph
|
||||
import dev.ulfrx.recipe.navigation.RecipesGraph
|
||||
import dev.ulfrx.recipe.navigation.RootNavHost
|
||||
import dev.ulfrx.recipe.navigation.ShoppingGraph
|
||||
import dev.ulfrx.recipe.navigation.navigateToTab
|
||||
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||
import dev.ulfrx.recipe.navigation.TabNavigator
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
@@ -48,7 +38,7 @@ import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
*
|
||||
* ## Layout
|
||||
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
||||
* - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource]
|
||||
* - Body: [RootNavDisplay] consumes the full screen, wrapped in [GlassBackdropSource]
|
||||
* so Liquid chrome samples the screen body through `LocalGlassBackdropState`.
|
||||
* - Bottom chrome (overlay): the active screen contributes its own contextual
|
||||
* chrome via [ShellChromeState] / [LocalShellChrome] (see [ShellChrome.kt]).
|
||||
@@ -61,9 +51,20 @@ import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
* - Pitfall F: navigationBars + ime padding only; no `safeContentPadding()`.
|
||||
*
|
||||
* ## Active-tab tracking
|
||||
* Derived from the NavHost's current back stack entry hierarchy via [hasRoute].
|
||||
* No mirror state in a ShellViewModel is needed — `currentBackStackEntryAsState()`
|
||||
* is the single source of truth, and re-derives synchronously after every nav.
|
||||
* Read directly from [TabNavigator.activeTab], which is `MutableState<…>` so
|
||||
* recomposition is automatic on tab switch. No mirror state needed — the
|
||||
* navigator is the single source of truth.
|
||||
*
|
||||
* ## Why TabNavigator and not the AndroidX NavController
|
||||
* Phase 2.1 originally wired the dock through a single Nav-2 `NavHost` with
|
||||
* four nested `navigation<…>` sub-graphs using `popUpTo + saveState +
|
||||
* restoreState` for multi-back-stack. Nav 3 replaces that with an app-owned
|
||||
* back stack (a `SnapshotStateList<NavKey>`), so [TabNavigator] holds **one
|
||||
* stack per tab** and [RootNavDisplay] renders only the active one inside a
|
||||
* `NavDisplay`. The implementation closely mirrors the
|
||||
* [To-Do-CMP reference](https://github.com/stevdza-san/To-Do-CMP) pattern —
|
||||
* `Navigator` + `NavDisplay(entryProvider = …)` — extended for the dock's
|
||||
* parallel-stack requirement.
|
||||
*
|
||||
* ## What used to live here, and why it moved
|
||||
* The previous version of this file knew about `RecipesSearchViewModel` and
|
||||
@@ -76,12 +77,7 @@ import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppShell(modifier: Modifier = Modifier) {
|
||||
val navController = rememberNavController()
|
||||
val backStack by navController.currentBackStackEntryAsState()
|
||||
val activeTab =
|
||||
remember(backStack) {
|
||||
backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
|
||||
}
|
||||
val navigator = remember { TabNavigator() }
|
||||
|
||||
// Single chrome-state holder for the lifetime of the shell. Provided to
|
||||
// descendants via LocalShellChrome.
|
||||
@@ -99,11 +95,11 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
.fillMaxSize()
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
// Body — RootNavHost fills the available space and is the shared source
|
||||
// Body — RootNavDisplay fills the available space and is the shared source
|
||||
// layer for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
|
||||
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
||||
RootNavHost(
|
||||
navController = navController,
|
||||
RootNavDisplay(
|
||||
navigator = navigator,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -140,8 +136,8 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
chrome.bottomOverlay?.invoke()
|
||||
} else {
|
||||
DefaultDockRow(
|
||||
activeTab = activeTab,
|
||||
onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
|
||||
activeTab = navigator.activeTab,
|
||||
onTabSelect = navigator::selectTab,
|
||||
trailingSlot = chrome.trailingSlot,
|
||||
)
|
||||
}
|
||||
@@ -176,20 +172,3 @@ private fun DefaultDockRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a [NavBackStackEntry]'s current route hierarchy to a [BottomBarDestination].
|
||||
* CMP nav-compose 2.9.2 supports type-safe [hasRoute] matching against
|
||||
* @Serializable graph types.
|
||||
*/
|
||||
private fun NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? {
|
||||
if (this == null) return null
|
||||
val hierarchy = destination.hierarchy
|
||||
return when {
|
||||
hierarchy.any { it.hasRoute(PlannerGraph::class) } -> BottomBarDestination.Planner
|
||||
hierarchy.any { it.hasRoute(RecipesGraph::class) } -> BottomBarDestination.Recipes
|
||||
hierarchy.any { it.hasRoute(PantryGraph::class) } -> BottomBarDestination.Pantry
|
||||
hierarchy.any { it.hasRoute(ShoppingGraph::class) } -> BottomBarDestination.Shopping
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user