From 8f4903a05509d904755eace40a84bda9e738cf77 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 12 May 2026 22:42:36 +0200 Subject: [PATCH] Switch from navigation 2 to navigation 3 --- composeApp/build.gradle.kts | 2 +- .../kotlin/dev/ulfrx/recipe/di/ShellModule.kt | 5 +- .../recipe/navigation/BottomBarDestination.kt | 17 ++-- .../ulfrx/recipe/navigation/NavExtensions.kt | 28 ------ .../ulfrx/recipe/navigation/RootNavDisplay.kt | 94 ++++++++++++++++++ .../ulfrx/recipe/navigation/RootNavHost.kt | 97 ------------------- .../dev/ulfrx/recipe/navigation/Routes.kt | 33 ------- .../dev/ulfrx/recipe/navigation/Screen.kt | 35 +++++++ .../ulfrx/recipe/navigation/TabNavigator.kt | 51 ++++++++++ .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 67 +++++-------- gradle/libs.versions.toml | 5 +- 11 files changed, 219 insertions(+), 215 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/NavExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Routes.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1411870..7e1526e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt index 4fcf08e..604bcb6 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt @@ -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() viewModel() viewModel() diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt index f02d71a..ccc2fa0 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt @@ -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, ), diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/NavExtensions.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/NavExtensions.kt deleted file mode 100644 index 0abb751..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/NavExtensions.kt +++ /dev/null @@ -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 - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt new file mode 100644 index 0000000..1853814 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt @@ -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 { + val vm: PlannerViewModel = koinViewModel() + PlannerScreen(viewModel = vm) + } + entry { + val vm: RecipesViewModel = koinViewModel() + val searchVm: RecipesSearchViewModel = koinViewModel() + RecipesScreen(viewModel = vm, searchViewModel = searchVm) + } + entry { + val vm: PantryViewModel = koinViewModel() + val searchVm: PantrySearchViewModel = koinViewModel() + PantryScreen(viewModel = vm, searchViewModel = searchVm) + } + entry { + val vm: ShoppingViewModel = koinViewModel() + ShoppingScreen(viewModel = vm) + } + }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt deleted file mode 100644 index c478c6b..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt +++ /dev/null @@ -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(startDestination = PlannerHome) { - composable { entry -> - val parent = - remember(entry) { - navController.getBackStackEntry(PlannerGraph) - } - val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent) - PlannerScreen(viewModel = vm) - } - // future: composable{ ... } - } - - // ---- Recipes graph ---- - navigation(startDestination = RecipesHome) { - composable { 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(startDestination = PantryHome) { - composable { 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(startDestination = ShoppingHome) { - composable { entry -> - val parent = - remember(entry) { - navController.getBackStackEntry(ShoppingGraph) - } - val vm: ShoppingViewModel = koinViewModel(viewModelStoreOwner = parent) - ShoppingScreen(viewModel = vm) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Routes.kt deleted file mode 100644 index f56c086..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Routes.kt +++ /dev/null @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt new file mode 100644 index 0000000..0e9074f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/Screen.kt @@ -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 + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt new file mode 100644 index 0000000..8024ae2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt @@ -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.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) } + + var activeTab: BottomBarDestination by mutableStateOf(initialTab) + private set + + val activeBackStack: SnapshotStateList + get() = backStacks.getValue(activeTab) + + fun backStackFor(tab: BottomBarDestination): SnapshotStateList = + 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) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt index 1ce8afd..8fb33a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt @@ -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`), 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 - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0517279..f189da9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ ktor = "3.4.2" lokksmith = "0.13.0" logback = "1.5.32" multiplatformSettings = "1.3.0" -navigation-compose = "2.9.2" +navigation3 = "1.1.1" compose-unstyled = "1.49.9" compose-icons = "2.2.1" liquid = "1.1.1" @@ -88,8 +88,7 @@ ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlin lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref = "lokksmith" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } -# Phase 2.1 — App shell foundation (UI-03, UI-04, UI-09, UI-10) -navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" } compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" } liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }