From 15d2d9ad13b81d84d8a29ce1a48b6f2ed54e0910 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Mon, 11 May 2026 22:01:34 +0200 Subject: [PATCH] Reorganize dock and search --- .../commonMain/kotlin/dev/ulfrx/recipe/App.kt | 2 +- .../kotlin/dev/ulfrx/recipe/di/ShellModule.kt | 9 +- .../recipe/navigation/BottomBarDestination.kt | 18 +- .../ulfrx/recipe/navigation/RootNavHost.kt | 8 +- .../components/dock/FloatingSearchButton.kt | 1 - .../ui/components/glass/GlassBackdrop.kt | 2 +- .../ui/components/search/SearchChrome.kt | 214 ++++++++++++ .../ui/components/search/SearchControls.kt | 57 ++++ .../recipe/ui/screens/pantry/PantryScreen.kt | 16 +- .../screens/pantry/PantrySearchViewModel.kt | 25 +- .../ui/screens/recipes/RecipesScreen.kt | 19 +- .../screens/recipes/RecipesSearchViewModel.kt | 46 +-- .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 318 ++++++------------ .../recipe/ui/screens/shell/ShellChrome.kt | 53 +++ .../recipe/ui/screens/shell/ShellViewModel.kt | 60 ---- 15 files changed, 510 insertions(+), 338 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChrome.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt index 61e4d13..0e3f0b4 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt @@ -68,7 +68,7 @@ fun App() { // RootRoute.Login -> LoginScreen(viewModel = koinViewModel()) // RootRoute.Shell -> AppShell() // } - //for easier tests authentication is turned off + // for easier tests authentication is turned off AppShell() } } 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 b804896..4fcf08e 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt @@ -5,24 +5,23 @@ import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel -import dev.ulfrx.recipe.ui.screens.shell.ShellViewModel import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import org.koin.dsl.module import org.koin.plugin.module.dsl.viewModel val shellModule = module { - // Shell-level state machine. - viewModel() - // 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. viewModel() viewModel() viewModel() viewModel() // Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject - // their respective SearchSource implementations. + // their respective SearchSource implementations. Both implement + // SearchControls so the shared ProvideSearchChrome composable drives them. 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 29136b9..f02d71a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt @@ -8,8 +8,6 @@ import com.composables.icons.lucide.Package import com.composables.icons.lucide.ShoppingCart import org.jetbrains.compose.resources.StringResource import recipe.composeapp.generated.resources.Res -import recipe.composeapp.generated.resources.search_placeholder_pantry -import recipe.composeapp.generated.resources.search_placeholder_recipes import recipe.composeapp.generated.resources.shell_tab_pantry import recipe.composeapp.generated.resources.shell_tab_planner import recipe.composeapp.generated.resources.shell_tab_recipes @@ -21,43 +19,35 @@ import recipe.composeapp.generated.resources.shell_tab_shopping * default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal listing * order, which research confirmed is non-binding. * - * `hasSearch` drives D-06: search affordance lives on Recipes + Pantry only. - * `searchPlaceholder` is non-null IFF `hasSearch` is true. + * Per-tab contextual chrome (search button on Recipes / Pantry, future filter + * buttons elsewhere) is owned by each screen via the slot pattern in + * [dev.ulfrx.recipe.ui.screens.shell.ShellChromeState]. This enum is therefore + * intentionally minimal: route + label + icon, nothing about feature affordances. */ enum class BottomBarDestination( val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector, - val hasSearch: Boolean, - val searchPlaceholder: StringResource?, ) { Planner( graphRoute = PlannerGraph, labelRes = Res.string.shell_tab_planner, icon = Lucide.CalendarDays, - hasSearch = false, - searchPlaceholder = null, ), Recipes( graphRoute = RecipesGraph, labelRes = Res.string.shell_tab_recipes, icon = Lucide.BookOpenText, - hasSearch = true, - searchPlaceholder = Res.string.search_placeholder_recipes, ), Pantry( graphRoute = PantryGraph, labelRes = Res.string.shell_tab_pantry, icon = Lucide.Package, - hasSearch = true, - searchPlaceholder = Res.string.search_placeholder_pantry, ), Shopping( graphRoute = ShoppingGraph, labelRes = Res.string.shell_tab_shopping, icon = Lucide.ShoppingCart, - hasSearch = false, - searchPlaceholder = null, ), ; diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt index 27e20f9..c478c6b 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt @@ -9,10 +9,12 @@ 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 @@ -62,7 +64,8 @@ fun RootNavHost( navController.getBackStackEntry(RecipesGraph) } val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent) - RecipesScreen(viewModel = vm) + val searchVm: RecipesSearchViewModel = koinViewModel(viewModelStoreOwner = parent) + RecipesScreen(viewModel = vm, searchViewModel = searchVm) } } @@ -74,7 +77,8 @@ fun RootNavHost( navController.getBackStackEntry(PantryGraph) } val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent) - PantryScreen(viewModel = vm) + val searchVm: PantrySearchViewModel = koinViewModel(viewModelStoreOwner = parent) + PantryScreen(viewModel = vm, searchViewModel = searchVm) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt index a4e4cc9..590fa13 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt @@ -57,4 +57,3 @@ fun FloatingSearchButton( } } } - diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt index 96d8bf7..7ec4339 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt @@ -28,7 +28,7 @@ fun rememberGlassBackdropState(): GlassBackdropState { val liquidState = rememberLiquidBackdropHandle() return remember(liquidState) { GlassBackdropState( - liquidState = liquidState + liquidState = liquidState, ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt new file mode 100644 index 0000000..2476b7d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt @@ -0,0 +1,214 @@ +package dev.ulfrx.recipe.ui.components.search + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import com.composeunstyled.UnstyledButton +import com.composeunstyled.UnstyledIcon +import dev.ulfrx.recipe.navigation.BottomBarDestination +import dev.ulfrx.recipe.ui.components.dock.DockBar +import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton +import dev.ulfrx.recipe.ui.components.glass.GlassSurface +import dev.ulfrx.recipe.ui.screens.shell.LocalShellChrome +import dev.ulfrx.recipe.ui.theme.RecipeTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import recipe.composeapp.generated.resources.Res +import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y + +/** + * Wires a feature's [SearchControls] VM into the shell's bottom-bar slots + * (`LocalShellChrome.trailingSlot` and `LocalShellChrome.bottomOverlay`). + * + * Call this once from any feature screen that wants the shared search affordance + * (Recipes, Pantry, …). The shell does not need to know which features have search; + * it just renders whatever slots the active screen has supplied. + * + * ## What it does + * + * - When `controls.state.isOpen == false`: trailing slot becomes a [FloatingSearchButton] + * that calls `controls.open()`. Bottom overlay stays null (default DockBar visible). + * + * - When `controls.state.isOpen == true`: bottom overlay takes over the row, + * showing a collapsed [DockBar] icon + a full-width [SearchPill] + an optional + * keyboard-dismiss button. + * + * ## Ownership protocol (important) + * + * The slot lambdas are built once per [SearchControls] instance via [remember] so + * their referential identity is stable across recompositions. On disposal we use + * a `===` identity check before nulling — that way, if the next active screen + * has already taken ownership of a slot in between (race during NavHost + * destination swap), our late-running disposer can't clobber it. + * + * @param controls per-feature search VM (Recipes / Pantry / …). + * @param placeholder localized placeholder for the [SearchPill] text field. + * @param activeTab passed to [DockBar] in collapsed mode so the right tab icon + * shows in the collapsed circle. Pass [BottomBarDestination] of the screen + * invoking this composable (e.g. `BottomBarDestination.Recipes`). + */ +@Composable +fun ProvideSearchChrome( + controls: SearchControls, + placeholder: StringResource, + activeTab: BottomBarDestination, +) { + val chrome = LocalShellChrome.current + val state by controls.state.collectAsStateWithLifecycle() + val placeholderText = stringResource(placeholder) + + // Stable slot lambdas — survive recomposition as long as `controls` and + // `placeholderText` don't change. Stable identity is what makes the `===` + // ownership guards in onDispose correct. + val trailing: @Composable () -> Unit = + remember(controls) { + { FloatingSearchButton(onClick = controls::open) } + } + val overlay: @Composable () -> Unit = + remember(controls, placeholderText, activeTab) { + { + // Subscribe to state INSIDE the slot so query updates only + // recompose this overlay, not the rest of AppShell. + val s by controls.state.collectAsStateWithLifecycle() + SearchPillRow( + query = s.query, + placeholder = placeholderText, + activeTab = activeTab, + onQueryChange = controls::onQueryChange, + onClose = controls::close, + onClear = controls::clear, + ) + } + } + + // Drive chrome slots from isOpen. DisposableEffect re-runs whenever + // isOpen flips, swapping which slot is populated. + DisposableEffect(state.isOpen, trailing, overlay, chrome) { + if (state.isOpen) { + chrome.trailingSlot = null + chrome.bottomOverlay = overlay + } else { + chrome.bottomOverlay = null + chrome.trailingSlot = trailing + } + onDispose { + // Only clear if WE'RE still the slot owner. A `===` check prevents + // a late dispose from clobbering slots already claimed by the next + // active screen. + if (chrome.trailingSlot === trailing) chrome.trailingSlot = null + if (chrome.bottomOverlay === overlay) chrome.bottomOverlay = null + } + } +} + +/** + * Replacement bottom row rendered when search is open: collapsed [DockBar] icon + * (tap = close search), full-width [SearchPill], and an optional X button to + * clear the query + dismiss the keyboard while the field is focused. + * + * Geometry mirrors the previous [dev.ulfrx.recipe.ui.screens.shell.AppShell] + * branch: 45dp height for all three children, capsule shapes, [RecipeTheme.spacing.sm] + * gap between cells. + */ +@Composable +private fun SearchPillRow( + query: String, + placeholder: String, + activeTab: BottomBarDestination, + onQueryChange: (String) -> Unit, + onClose: () -> Unit, + onClear: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val pillHeight = 45.dp + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + DockBar( + destinations = BottomBarDestination.entries, + active = activeTab, + collapsed = true, + onTabSelect = { /* unreachable while collapsed */ }, + onCollapsedTap = onClose, + height = pillHeight, + ) + SearchPill( + query = query, + onQueryChange = onQueryChange, + onFocusChanged = { focused = it }, + placeholder = placeholder, + modifier = Modifier.weight(1f), + height = pillHeight, + ) + if (focused) { + DismissSearchKeyboardButton( + onClick = { + onClear() + focusManager.clearFocus() + focused = false + }, + size = pillHeight, + ) + } + } +} + +/** + * 45dp circular Liquid-glass button that clears the active query and dismisses + * the keyboard. Visible only while the [SearchPill] field is focused. + * + * Lifted verbatim from the previous private helper in + * [dev.ulfrx.recipe.ui.screens.shell.AppShell] — moved here because keyboard + * dismissal is conceptually part of the search affordance, not the shell. + */ +@Composable +private fun DismissSearchKeyboardButton( + onClick: () -> Unit, + size: Dp, +) { + val a11y = stringResource(Res.string.search_dismiss_keyboard_a11y) + GlassSurface( + modifier = Modifier.size(size), + cornerRadius = size / 2, + ) { + UnstyledButton( + onClick = onClick, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + UnstyledIcon( + imageVector = Lucide.X, + contentDescription = a11y, + tint = RecipeTheme.colors.content, + modifier = Modifier.size(24.dp), + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt new file mode 100644 index 0000000..de58d8d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt @@ -0,0 +1,57 @@ +package dev.ulfrx.recipe.ui.components.search + +import kotlinx.coroutines.flow.StateFlow + +/** + * Per-tab search state shape. Both [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel] + * and [dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel] expose this via + * their `state: StateFlow`. + * + * - [isOpen] — whether the search affordance is open on this tab. + * - [query] — the current query echo (D-07: just an echo this phase; results + * plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively). + */ +data class SearchState( + val isOpen: Boolean = false, + val query: String = "", +) + +/** + * Phase 5 (Recipes) and Phase 8 (Pantry) implement and inject a real + * [SearchSource]; Phase 2.1 leaves it null. The Search VMs accept a nullable + * source today so Phase 5 / 8 only inject a dependency, not refactor the VM. + * + * Defined in `ui.components.search` (the canonical home for search shapes) — + * Phase 5 introduces the Recipes-specific implementation; Phase 8 either reuses + * or shadows with its own version. Either way, Phase 2.1 does NOT call into + * [SearchSource]. + */ +interface SearchSource { + // Phase 5 / 8 add: fun observe(query: String): Flow> +} + +/** + * Minimal contract a feature ViewModel must satisfy to participate in the + * shared bottom-bar search chrome via [ProvideSearchChrome]. + * + * Both Recipes and Pantry search VMs already had this exact shape — making it + * an explicit interface lets [ProvideSearchChrome] take a stable VM reference + * and keep its slot lambdas referentially stable across recompositions + * (important for the `===` identity guard in the chrome ownership protocol). + */ +interface SearchControls { + /** Hot state stream — UI subscribes via `collectAsStateWithLifecycle()`. */ + val state: StateFlow + + /** Open the search affordance. */ + fun open() + + /** Close the search affordance. D-08: closing also clears the query. */ + fun close() + + /** Update the query echo. */ + fun onQueryChange(q: String) + + /** D-07: clear the query but keep the affordance open. */ + fun clear() +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt index 8536d4c..894a8ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt @@ -16,22 +16,36 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.ui.components.empty.EmptyState +import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.empty_pantry_subtitle import recipe.composeapp.generated.resources.empty_pantry_title +import recipe.composeapp.generated.resources.search_placeholder_pantry import recipe.composeapp.generated.resources.shell_tab_pantry /** * Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the * empty body with the inventory list. + * + * Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not + * know that Pantry has a search button. */ @Composable -fun PantryScreen(viewModel: PantryViewModel) { +fun PantryScreen( + viewModel: PantryViewModel, + searchViewModel: PantrySearchViewModel, +) { @Suppress("UNUSED_VARIABLE") val state by viewModel.state.collectAsStateWithLifecycle() + ProvideSearchChrome( + controls = searchViewModel, + placeholder = Res.string.search_placeholder_pantry, + activeTab = BottomBarDestination.Pantry, + ) + Box( modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), ) { diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt index f7e2a39..c3e99df 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt @@ -1,17 +1,19 @@ package dev.ulfrx.recipe.ui.screens.pantry import androidx.lifecycle.ViewModel -import dev.ulfrx.recipe.ui.screens.recipes.SearchSource -import dev.ulfrx.recipe.ui.screens.recipes.SearchState +import dev.ulfrx.recipe.ui.components.search.SearchControls +import dev.ulfrx.recipe.ui.components.search.SearchSource +import dev.ulfrx.recipe.ui.components.search.SearchState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update /** - * PantrySearchViewModel — semantic parity with [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel]. - * Both VMs share [SearchState] and [SearchSource] from `ui.screens.recipes` (the - * canonical home for the search-state shape). + * PantrySearchViewModel — semantic parity with + * [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel]. Both VMs share + * [SearchState] and [SearchSource] from `ui.components.search` and implement + * [SearchControls] so the same `ProvideSearchChrome` helper drives both tabs. * * Phase 8 (Pantry) injects a Pantry-specific SearchSource. This phase: pure echo. * Constructor parameter has a default so Koin can register without a source today. @@ -19,25 +21,26 @@ import kotlinx.coroutines.flow.update class PantrySearchViewModel( @Suppress("UNUSED_PARAMETER") private val searchSource: SearchSource? = null, -) : ViewModel() { +) : ViewModel(), + SearchControls { private val _state = MutableStateFlow(SearchState()) - val state: StateFlow = _state.asStateFlow() + override val state: StateFlow = _state.asStateFlow() - fun open() { + override fun open() { _state.update { it.copy(isOpen = true) } } /** D-08: closing clears the query. */ - fun close() { + override fun close() { _state.value = SearchState(isOpen = false, query = "") } - fun onQueryChange(q: String) { + override fun onQueryChange(q: String) { _state.update { it.copy(query = q) } } /** D-07: clear() resets only the query, preserves isOpen. */ - fun clear() { + override fun clear() { _state.update { it.copy(query = "") } } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt index 60960cf..f496ffc 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt @@ -16,22 +16,39 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.ui.components.empty.EmptyState +import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.empty_recipes_subtitle import recipe.composeapp.generated.resources.empty_recipes_title +import recipe.composeapp.generated.resources.search_placeholder_recipes import recipe.composeapp.generated.resources.shell_tab_recipes /** * Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the * empty body with the recipe catalog grid. + * + * Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not + * know that Recipes has a search button. When this screen leaves composition + * (tab switch), the chrome slots clear themselves automatically. */ @Composable -fun RecipesScreen(viewModel: RecipesViewModel) { +fun RecipesScreen( + viewModel: RecipesViewModel, + searchViewModel: RecipesSearchViewModel, +) { @Suppress("UNUSED_VARIABLE") val state by viewModel.state.collectAsStateWithLifecycle() + // Push search chrome (FloatingSearchButton / SearchPill overlay) into the + // shell's bottom-bar slots. Lifecycle is tied to this composition. + ProvideSearchChrome( + controls = searchViewModel, + placeholder = Res.string.search_placeholder_recipes, + activeTab = BottomBarDestination.Recipes, + ) + Box( modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), ) { diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt index eb1793f..54a99db 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt @@ -1,68 +1,50 @@ package dev.ulfrx.recipe.ui.screens.recipes import androidx.lifecycle.ViewModel +import dev.ulfrx.recipe.ui.components.search.SearchControls +import dev.ulfrx.recipe.ui.components.search.SearchSource +import dev.ulfrx.recipe.ui.components.search.SearchState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -/** - * Per-tab search state for [RecipesSearchViewModel] and [PantrySearchViewModel] - * (RESEARCH § Pattern 4, lines 390-410). - * - * - [isOpen] — whether the search affordance is open on this tab. - * - [query] — the current query echo (D-07: just an echo this phase; results - * plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively). - */ -data class SearchState( - val isOpen: Boolean = false, - val query: String = "", -) - -/** - * Phase 5 (Recipes) and Phase 8 (Pantry) implement and inject a real - * [SearchSource]; Phase 2.1 leaves it null. The Search VMs accept a nullable - * source today so Phase 5 / 8 only inject a dependency, not refactor the VM. - * - * Defined here (in `recipes/` package) as a marker — Phase 5 introduces the - * Recipes-specific implementation; Phase 8 may either reuse or shadow with its - * own version. Either way, this phase does NOT call into [SearchSource]. - */ -interface SearchSource { - // Phase 5 / 8 add: fun observe(query: String): Flow> -} - /** * RecipesSearchViewModel per RESEARCH § Pattern 4. Pure state machine; no I/O * this phase (the [searchSource] parameter is the Phase 5 extension hook — * RESEARCH line 410). Constructor parameter has a default so Koin can register * with `viewModel { RecipesSearchViewModel() }` and Phase 5 swaps to * `viewModel { RecipesSearchViewModel(searchSource = get()) }`. + * + * Implements [SearchControls] so it can plug into the shared + * [dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome] helper alongside + * [dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel]. */ class RecipesSearchViewModel( @Suppress("UNUSED_PARAMETER") private val searchSource: SearchSource? = null, -) : ViewModel() { +) : ViewModel(), + SearchControls { private val _state = MutableStateFlow(SearchState()) - val state: StateFlow = _state.asStateFlow() + override val state: StateFlow = _state.asStateFlow() /** Open the search affordance. */ - fun open() { + override fun open() { _state.update { it.copy(isOpen = true) } } /** D-08: closing clears the query — reopening starts blank. */ - fun close() { + override fun close() { _state.value = SearchState(isOpen = false, query = "") } /** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */ - fun onQueryChange(q: String) { + override fun onQueryChange(q: String) { _state.update { it.copy(query = q) } } /** D-07: clear() resets only the query and keeps isOpen=true. */ - fun clear() { + override fun clear() { _state.update { it.copy(query = "") } } } 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 19643dc..1ce8afd 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 @@ -1,9 +1,14 @@ package dev.ulfrx.recipe.ui.screens.shell +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.FastOutSlowInEasing +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.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -14,27 +19,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 com.composables.icons.lucide.Lucide -import com.composables.icons.lucide.X -import com.composeunstyled.UnstyledButton -import com.composeunstyled.UnstyledIcon import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.PantryGraph import dev.ulfrx.recipe.navigation.PlannerGraph @@ -43,35 +39,39 @@ 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.FloatingSearchButton import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource -import dev.ulfrx.recipe.ui.components.glass.GlassSurface -import dev.ulfrx.recipe.ui.components.search.SearchPill -import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel -import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel import dev.ulfrx.recipe.ui.theme.RecipeTheme -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import recipe.composeapp.generated.resources.Res -import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y /** - * Authenticated root composable per RESEARCH § Code Example 2 (lines 514-565). + * Authenticated root composable. Hosts navigation and the bottom-bar chrome + * skeleton, but is **agnostic about feature concerns** like search. * - * Layout responsibilities: + * ## Layout * - Background: full-screen [RecipeTheme.colors.background] under the safe area. * - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource] - * so Liquid chrome samples the screen body through [LocalGlassBackdropState]. - * - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill] - * (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible). - * Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per - * Pitfall F — does NOT use safeContentPadding() at this layer. - * - [FloatingSearchButton] aligned [Alignment.BottomEnd], visible only when - * !ui.searchOpen && active.hasSearch (D-06). + * 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]). + * AppShell renders one of two modes: + * * `bottomOverlay` non-null → render the screen-supplied overlay full-width. + * * `bottomOverlay` null → render the default [DockBar] + a 56dp trailing + * slot whose contents come from `chrome.trailingSlot` (or empty). + * - The two modes are wrapped in [AnimatedContent] for a smooth cross-fade when a + * feature toggles its overlay (e.g. opening / closing search). + * - Pitfall F: navigationBars + ime padding only; no `safeContentPadding()`. * - * Active-tab tracking: derived from the NavHost's current back stack entry's route - * hierarchy via [hasRoute]. The shell's [ShellViewModel] mirrors active tab so chrome - * can react synchronously even before NavHost navigation completes. + * ## 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. + * + * ## What used to live here, and why it moved + * The previous version of this file knew about `RecipesSearchViewModel` and + * `PantrySearchViewModel` directly, with `when (activeTab)` branches forwarding + * open/close/clear into the right VM. That coupling was unnecessary: the active + * screen already has the VM and is the right place to express its own chrome. + * The slot pattern means adding a contextual button to a future tab (a Planner + * filter, say) is a one-file change in that screen — AppShell never grows. */ @Preview @Composable @@ -83,162 +83,66 @@ fun AppShell(modifier: Modifier = Modifier) { backStack?.toBottomBarDestination() ?: BottomBarDestination.Default } - val vm: ShellViewModel = koinViewModel() - val ui by vm.state.collectAsStateWithLifecycle() - val recipesSearchVm: RecipesSearchViewModel = koinViewModel() - val recipesSearch by recipesSearchVm.state.collectAsStateWithLifecycle() - val pantrySearchVm: PantrySearchViewModel = koinViewModel() - val pantrySearch by pantrySearchVm.state.collectAsStateWithLifecycle() - val focusManager = LocalFocusManager.current - var searchFieldFocused by remember { mutableStateOf(false) } - val dockHeight = 56.dp - val activeSearchHeight = 45.dp + // Single chrome-state holder for the lifetime of the shell. Provided to + // descendants via LocalShellChrome. + val chrome = remember { ShellChromeState() } - fun closeActiveSearch() { - when (activeTab) { - BottomBarDestination.Recipes -> recipesSearchVm.close() - BottomBarDestination.Pantry -> pantrySearchVm.close() - else -> Unit - } - vm.closeSearch() - searchFieldFocused = false - } + // NB: we deliberately do NOT clear chrome on activeTab changes. The active + // screen's `DisposableEffect` cleanup (in `ProvideSearchChrome` or similar) + // is responsible for releasing its slots when it leaves composition. Doing + // a redundant clear here would race with the new screen's setup. - fun clearActiveSearchAndDismissKeyboard() { - when (activeTab) { - BottomBarDestination.Recipes -> recipesSearchVm.clear() - BottomBarDestination.Pantry -> pantrySearchVm.clear() - else -> Unit - } - focusManager.clearFocus() - searchFieldFocused = false - } - - // Sync ShellViewModel.activeTab with NavHost-derived activeTab for back-button - // and deep-link cases. onTabChanged also clears any open search per D-08. - LaunchedEffect(activeTab) { - if (ui.activeTab != activeTab) { - vm.onTabChanged(activeTab) - } - searchFieldFocused = false - } - - LaunchedEffect(ui.searchOpen) { - if (!ui.searchOpen) { - searchFieldFocused = false - focusManager.clearFocus() - } - } - - Box( - modifier = - modifier - .fillMaxSize() - .background(RecipeTheme.colors.background), - ) { - // Body — RootNavHost 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, - modifier = Modifier.fillMaxSize(), - ) - } - - // Bottom chrome overlay — single Row spanning the full width with two - // layout modes: - // - Closed: DockBar (fills, weighted 4 tabs) + 56dp trailing slot - // that holds FloatingSearchButton on Recipes/Pantry (D-06), empty - // on other tabs (placeholder for future contextual buttons). - // - Open: collapsed dock icon button (56dp left) + SearchPill (fills) - // + optional 56dp keyboard-dismiss button while the field is focused. - // Pitfall F: navigationBars + ime padding only; no safeContentPadding. - Row( + CompositionLocalProvider(LocalShellChrome provides chrome) { + Box( modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) - .imePadding() - .padding( - horizontal = RecipeTheme.spacing.lg, - vertical = RecipeTheme.spacing.sm, - ), - horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), - verticalAlignment = Alignment.CenterVertically, + modifier + .fillMaxSize() + .background(RecipeTheme.colors.background), ) { - if (ui.searchOpen && activeTab.hasSearch) { - DockBar( - destinations = BottomBarDestination.entries, - active = activeTab, - collapsed = true, - onTabSelect = { /* unreachable while collapsed */ }, - onCollapsedTap = { closeActiveSearch() }, - height = activeSearchHeight, + // Body — RootNavHost 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, + modifier = Modifier.fillMaxSize(), ) - val placeholderRes = activeTab.searchPlaceholder - if (placeholderRes != null) { - val pillModifier = Modifier.weight(1f) - when (activeTab) { - BottomBarDestination.Recipes -> { - SearchPill( - query = recipesSearch.query, - onQueryChange = { recipesSearchVm.onQueryChange(it) }, - onFocusChanged = { searchFieldFocused = it }, - placeholder = stringResource(placeholderRes), - modifier = pillModifier, - height = activeSearchHeight, - ) - } + } - BottomBarDestination.Pantry -> { - SearchPill( - query = pantrySearch.query, - onQueryChange = { pantrySearchVm.onQueryChange(it) }, - onFocusChanged = { searchFieldFocused = it }, - placeholder = stringResource(placeholderRes), - modifier = pillModifier, - height = activeSearchHeight, - ) - } - - else -> { - Box(modifier = pillModifier) - } - } - } else { - Box(modifier = Modifier.weight(1f)) - } - if (searchFieldFocused) { - DismissSearchKeyboardButton( - onClick = { clearActiveSearchAndDismissKeyboard() }, - size = activeSearchHeight, - ) - } - } else { - DockBar( - destinations = BottomBarDestination.entries, - active = activeTab, - collapsed = false, - onTabSelect = { dest -> - navController.navigateToTab(dest.graphRoute) - vm.onTabChanged(dest) + // Bottom chrome — one Row, two layout modes (default / overlay) chosen + // by AnimatedContent for a clean cross-fade. + Row( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .imePadding() + .padding( + horizontal = RecipeTheme.spacing.lg, + vertical = RecipeTheme.spacing.sm, + ), + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + val overlay = chrome.bottomOverlay + AnimatedContent( + targetState = overlay != null, + modifier = Modifier.fillMaxWidth(), + transitionSpec = { + fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing)) }, - onCollapsedTap = { closeActiveSearch() }, - modifier = Modifier.weight(1f), - height = dockHeight, - ) - Box(modifier = Modifier.size(56.dp)) { - if (activeTab.hasSearch) { - FloatingSearchButton( - onClick = { - when (activeTab) { - BottomBarDestination.Recipes -> recipesSearchVm.open() - BottomBarDestination.Pantry -> pantrySearchVm.open() - else -> Unit - } - vm.openSearch() - }, + label = "AppShell bottom chrome", + ) { useOverlay -> + if (useOverlay) { + // Re-read current overlay inside this branch — chrome state + // can change after the targetState was captured. + chrome.bottomOverlay?.invoke() + } else { + DefaultDockRow( + activeTab = activeTab, + onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) }, + trailingSlot = chrome.trailingSlot, ) } } @@ -247,41 +151,37 @@ fun AppShell(modifier: Modifier = Modifier) { } } -/** - * Maps a [NavBackStackEntry]'s current route hierarchy to a [BottomBarDestination]. - * Inspects the destination hierarchy for the parent graph route; CMP nav-compose - * 2.9.2 supports type-safe [hasRoute] matching against @Serializable graph types. - */ @Composable -private fun DismissSearchKeyboardButton( - onClick: () -> Unit, - size: Dp, +private fun DefaultDockRow( + activeTab: BottomBarDestination, + onTabSelect: (BottomBarDestination) -> Unit, + trailingSlot: (@Composable () -> Unit)?, ) { - val a11y = stringResource(Res.string.search_dismiss_keyboard_a11y) - GlassSurface( - modifier = Modifier.size(size), - cornerRadius = size / 2, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, ) { - UnstyledButton( - onClick = onClick, - contentPadding = PaddingValues(0.dp), - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - UnstyledIcon( - imageVector = Lucide.X, - contentDescription = a11y, - tint = RecipeTheme.colors.content, - modifier = Modifier.size(24.dp), - ) - } + DockBar( + destinations = BottomBarDestination.entries, + active = activeTab, + collapsed = false, + onTabSelect = onTabSelect, + onCollapsedTap = { /* unreachable in default mode */ }, + modifier = Modifier.weight(1f), + height = 56.dp, + ) + Box(modifier = Modifier.size(56.dp)) { + trailingSlot?.invoke() } } } +/** + * 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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChrome.kt new file mode 100644 index 0000000..818ece3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChrome.kt @@ -0,0 +1,53 @@ +package dev.ulfrx.recipe.ui.screens.shell + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Slot-based contract between [AppShell] and the currently-active feature screen. + * + * The shell is intentionally agnostic about *what* contextual chrome each tab needs. + * Instead, every screen is handed a [ShellChromeState] via [LocalShellChrome] and + * pushes its own trailing button / bottom overlay into it. The shell only renders + * the slots — it does not know about search, filters, or any other feature concept. + * + * Two slots are exposed: + * + * - [trailingSlot] — composable rendered in the 56dp slot to the right of the + * [DockBar] in default mode. `null` means the slot is empty (placeholder for + * future contextual buttons on tabs that don't currently use it). + * + * - [bottomOverlay] — when non-null, the shell renders this **instead of** the + * default `DockBar + trailingSlot` row. Features use this to take over the + * bottom chrome entirely (e.g. Recipes / Pantry rendering a collapsed dock + + * [SearchPill] + dismiss button while search is open). + * + * Lifecycle: every screen that writes to these slots **must** clear them in an + * `onDispose { ... }` block. The recommended pattern (see `ProvideSearchChrome`) + * uses a `===` identity guard so a late-running disposer can't clobber slots + * that the next screen has already taken ownership of. + * + * The state holder itself is created once in [AppShell] and provided down the tree + * via [staticCompositionLocalOf] — its identity never changes for the lifetime of + * the shell, so `staticCompositionLocalOf` (which skips dependency tracking and + * recomposes the whole subtree on change) is the right primitive here. + */ +@Stable +class ShellChromeState { + var trailingSlot: (@Composable () -> Unit)? by mutableStateOf(null) + var bottomOverlay: (@Composable () -> Unit)? by mutableStateOf(null) +} + +/** + * Reads the [ShellChromeState] supplied by the nearest [AppShell] ancestor. + * Throws if no shell is in the composition — feature screens are always meant + * to render inside an [AppShell]. + */ +val LocalShellChrome = + staticCompositionLocalOf { + error("ShellChromeState not provided — wrap content in AppShell { ... }") + } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt deleted file mode 100644 index 337248d..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.shell - -import androidx.lifecycle.ViewModel -import dev.ulfrx.recipe.navigation.BottomBarDestination -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -/** - * Immutable UI state for [AppShell]. The shell tracks two things: - * - [activeTab] which tab is currently selected (mirrors NavHost back-stack head). - * - [searchOpen] whether the search affordance is open (D-06: only valid when - * [activeTab].hasSearch is true). - * - * Query text deliberately lives in the active tab's SearchViewModel - * (RecipesSearchViewModel or PantrySearchViewModel from plan 02.1-06). This keeps - * Phase 5's extension hook connected to the UI that the user actually sees. - */ -data class ShellState( - val activeTab: BottomBarDestination = BottomBarDestination.Default, - val searchOpen: Boolean = false, -) - -/** - * Active-tab + search state machine for the shell. Pure synchronous state - * transitions — no I/O, no viewModelScope.launch. Mirrors LoginViewModel's - * VM+StateFlow+method-per-action shape (CLAUDE.md project convention). - * - * Note: per-tab Search VMs (Recipes, Pantry — plan 02.1-06) own query and clear - * behavior. ShellViewModel mirrors search OPEN status here so the dock and floating - * button can react synchronously. - */ -class ShellViewModel : ViewModel() { - private val _state = MutableStateFlow(ShellState()) - val state: StateFlow = _state.asStateFlow() - - /** D-05 / D-06: open the search affordance on the active tab. No-op if the - * active tab has no search (defensive — UI is supposed to gate the call). */ - fun openSearch() { - _state.update { current -> - if (!current.activeTab.hasSearch) { - current - } else { - current.copy(searchOpen = true) - } - } - } - - /** D-08 shell half: closing hides search. AppShell also calls activeSearchVm.close(). */ - fun closeSearch() { - _state.update { it.copy(searchOpen = false) } - } - - /** Tab change — also closes any open search per D-08 (closing on tab switch is - * the same semantic: search state does not persist across tab switch). */ - fun onTabChanged(dest: BottomBarDestination) { - _state.update { ShellState(activeTab = dest, searchOpen = false) } - } -}