Switch from navigation 2 to navigation 3
This commit is contained in:
@@ -90,7 +90,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.serializationJson)
|
implementation(libs.kotlinx.serializationJson)
|
||||||
implementation(libs.multiplatform.settings)
|
implementation(libs.multiplatform.settings)
|
||||||
implementation(libs.lokksmith.compose)
|
implementation(libs.lokksmith.compose)
|
||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation3.ui)
|
||||||
implementation(libs.compose.unstyled)
|
implementation(libs.compose.unstyled)
|
||||||
implementation(libs.compose.icons.lucide)
|
implementation(libs.compose.icons.lucide)
|
||||||
implementation(libs.liquid)
|
implementation(libs.liquid)
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import org.koin.plugin.module.dsl.viewModel
|
|||||||
val shellModule =
|
val shellModule =
|
||||||
module {
|
module {
|
||||||
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
|
// Tab ViewModels — empty-state-only this phase; feature phases extend them.
|
||||||
// Active-tab tracking lives in NavController (currentBackStackEntryAsState),
|
// Active-tab tracking lives in TabNavigator (a `remember`-scoped state holder
|
||||||
// not in a shell-level VM, so there is no ShellViewModel to register.
|
// owned by AppShell), not in a shell-level VM, so there is no ShellViewModel
|
||||||
|
// to register.
|
||||||
viewModel<PlannerViewModel>()
|
viewModel<PlannerViewModel>()
|
||||||
viewModel<RecipesViewModel>()
|
viewModel<RecipesViewModel>()
|
||||||
viewModel<PantryViewModel>()
|
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:
|
* The 4 bottom-bar destinations in left→right order per CONTEXT D-03:
|
||||||
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
|
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
|
||||||
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing
|
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal
|
||||||
* order, which research confirmed is non-binding.
|
* 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
|
* Per-tab contextual chrome (search button on Recipes / Pantry, future filter
|
||||||
* buttons elsewhere) is owned by each screen via the slot pattern in
|
* 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.
|
* intentionally minimal: route + label + icon, nothing about feature affordances.
|
||||||
*/
|
*/
|
||||||
enum class BottomBarDestination(
|
enum class BottomBarDestination(
|
||||||
val graphRoute: Any,
|
val startDestination: Screen,
|
||||||
val labelRes: StringResource,
|
val labelRes: StringResource,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
) {
|
) {
|
||||||
Planner(
|
Planner(
|
||||||
graphRoute = PlannerGraph,
|
startDestination = Screen.Planner.Home,
|
||||||
labelRes = Res.string.shell_tab_planner,
|
labelRes = Res.string.shell_tab_planner,
|
||||||
icon = Lucide.CalendarDays,
|
icon = Lucide.CalendarDays,
|
||||||
),
|
),
|
||||||
Recipes(
|
Recipes(
|
||||||
graphRoute = RecipesGraph,
|
startDestination = Screen.Recipes.Home,
|
||||||
labelRes = Res.string.shell_tab_recipes,
|
labelRes = Res.string.shell_tab_recipes,
|
||||||
icon = Lucide.BookOpenText,
|
icon = Lucide.BookOpenText,
|
||||||
),
|
),
|
||||||
Pantry(
|
Pantry(
|
||||||
graphRoute = PantryGraph,
|
startDestination = Screen.Pantry.Home,
|
||||||
labelRes = Res.string.shell_tab_pantry,
|
labelRes = Res.string.shell_tab_pantry,
|
||||||
icon = Lucide.Package,
|
icon = Lucide.Package,
|
||||||
),
|
),
|
||||||
Shopping(
|
Shopping(
|
||||||
graphRoute = ShoppingGraph,
|
startDestination = Screen.Shopping.Home,
|
||||||
labelRes = Res.string.shell_tab_shopping,
|
labelRes = Res.string.shell_tab_shopping,
|
||||||
icon = Lucide.ShoppingCart,
|
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.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.navigation.PantryGraph
|
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||||
import dev.ulfrx.recipe.navigation.PlannerGraph
|
import dev.ulfrx.recipe.navigation.TabNavigator
|
||||||
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.ui.components.dock.DockBar
|
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
@@ -48,7 +38,7 @@ import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
|||||||
*
|
*
|
||||||
* ## Layout
|
* ## Layout
|
||||||
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
* - 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`.
|
* so Liquid chrome samples the screen body through `LocalGlassBackdropState`.
|
||||||
* - Bottom chrome (overlay): the active screen contributes its own contextual
|
* - Bottom chrome (overlay): the active screen contributes its own contextual
|
||||||
* chrome via [ShellChromeState] / [LocalShellChrome] (see [ShellChrome.kt]).
|
* 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()`.
|
* - Pitfall F: navigationBars + ime padding only; no `safeContentPadding()`.
|
||||||
*
|
*
|
||||||
* ## Active-tab tracking
|
* ## Active-tab tracking
|
||||||
* Derived from the NavHost's current back stack entry hierarchy via [hasRoute].
|
* Read directly from [TabNavigator.activeTab], which is `MutableState<…>` so
|
||||||
* No mirror state in a ShellViewModel is needed — `currentBackStackEntryAsState()`
|
* recomposition is automatic on tab switch. No mirror state needed — the
|
||||||
* is the single source of truth, and re-derives synchronously after every nav.
|
* 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
|
* ## What used to live here, and why it moved
|
||||||
* The previous version of this file knew about `RecipesSearchViewModel` and
|
* The previous version of this file knew about `RecipesSearchViewModel` and
|
||||||
@@ -76,12 +77,7 @@ import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AppShell(modifier: Modifier = Modifier) {
|
fun AppShell(modifier: Modifier = Modifier) {
|
||||||
val navController = rememberNavController()
|
val navigator = remember { TabNavigator() }
|
||||||
val backStack by navController.currentBackStackEntryAsState()
|
|
||||||
val activeTab =
|
|
||||||
remember(backStack) {
|
|
||||||
backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single chrome-state holder for the lifetime of the shell. Provided to
|
// Single chrome-state holder for the lifetime of the shell. Provided to
|
||||||
// descendants via LocalShellChrome.
|
// descendants via LocalShellChrome.
|
||||||
@@ -99,11 +95,11 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(RecipeTheme.colors.background),
|
.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).
|
// layer for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
|
||||||
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
||||||
RootNavHost(
|
RootNavDisplay(
|
||||||
navController = navController,
|
navigator = navigator,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -140,8 +136,8 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
chrome.bottomOverlay?.invoke()
|
chrome.bottomOverlay?.invoke()
|
||||||
} else {
|
} else {
|
||||||
DefaultDockRow(
|
DefaultDockRow(
|
||||||
activeTab = activeTab,
|
activeTab = navigator.activeTab,
|
||||||
onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
|
onTabSelect = navigator::selectTab,
|
||||||
trailingSlot = chrome.trailingSlot,
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ktor = "3.4.2"
|
|||||||
lokksmith = "0.13.0"
|
lokksmith = "0.13.0"
|
||||||
logback = "1.5.32"
|
logback = "1.5.32"
|
||||||
multiplatformSettings = "1.3.0"
|
multiplatformSettings = "1.3.0"
|
||||||
navigation-compose = "2.9.2"
|
navigation3 = "1.1.1"
|
||||||
compose-unstyled = "1.49.9"
|
compose-unstyled = "1.49.9"
|
||||||
compose-icons = "2.2.1"
|
compose-icons = "2.2.1"
|
||||||
liquid = "1.1.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" }
|
lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref = "lokksmith" }
|
||||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
||||||
|
|
||||||
# Phase 2.1 — App shell foundation (UI-03, UI-04, UI-09, UI-10)
|
navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
||||||
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
|
|
||||||
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
||||||
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" }
|
||||||
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
||||||
|
|||||||
Reference in New Issue
Block a user