Switch from navigation 2 to navigation 3

This commit is contained in:
2026-05-12 22:42:36 +02:00
parent 15d2d9ad13
commit 8f4903a055
11 changed files with 219 additions and 215 deletions

View File

@@ -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>()

View File

@@ -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,
),

View File

@@ -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
}
}

View File

@@ -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)
}
},
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}