diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7e1526e..8ed9a4b 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -77,6 +77,7 @@ kotlin { implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.ui) + implementation(libs.compose.ui.backhandler) implementation(libs.compose.components.resources) implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 676b9f2..0153a12 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -19,11 +19,14 @@ Spiżarnia Zakupy - - Szukaj przepisów… - Szukaj w spiżarni… - Szukaj w planie… - Szukaj na liście… + + Szukaj… + + + Tu pojawią się szybkie skróty + Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji. + Brak wyników + Zacznij pisać, aby wyszukać. Otwórz wyszukiwanie 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 834c1d9..1739354 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt @@ -1,12 +1,9 @@ package dev.ulfrx.recipe.di -import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel -import dev.ulfrx.recipe.ui.screens.planner.PlannerSearchViewModel 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.shopping.ShoppingSearchViewModel +import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import org.koin.dsl.module import org.koin.plugin.module.dsl.viewModel @@ -22,11 +19,9 @@ val shellModule = viewModel() viewModel() - // Per-tab Search ViewModels — pure echo this phase; Phase 5 / 6 / 8 / 9 - // inject their respective SearchSource implementations. All implement - // SearchControls so the shared ProvideSearchChrome composable drives them. - viewModel() - viewModel() - viewModel() - viewModel() + // Shell-wide search VM — single global state machine (closed / open + // unfocused / open focused) shared by the SearchScreen body and the + // SearchPillRow chrome. Per-tab SearchViewModels were retired when search + // moved from per-tab inline overlay to a shell-level destination. + 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 ccc2fa0..c770e2a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt @@ -22,10 +22,10 @@ import recipe.composeapp.generated.resources.shell_tab_shopping * 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 - * [dev.ulfrx.recipe.ui.screens.shell.ShellChromeState]. This enum is therefore - * intentionally minimal: route + label + icon, nothing about feature affordances. + * Search is a shell-wide affordance (see + * [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) — it lives outside + * the tab destinations entirely. This enum is intentionally minimal: route + + * label + icon, nothing about feature affordances. */ enum class BottomBarDestination( val startDestination: Screen, diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt index 8ffdf4d..f210271 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt @@ -11,16 +11,12 @@ 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.PlannerSearchViewModel 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.ShoppingSearchViewModel import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import org.koin.compose.viewmodel.koinViewModel @@ -52,6 +48,10 @@ import org.koin.compose.viewmodel.koinViewModel * 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(...)`). + * + * ## Search note + * Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not + * a tab destination — it lives outside this NavDisplay entirely. */ @Composable fun RootNavDisplay( @@ -74,23 +74,19 @@ fun RootNavDisplay( entryProvider = entryProvider { entry { val vm: PlannerViewModel = koinViewModel() - val searchVm: PlannerSearchViewModel = koinViewModel() - PlannerScreen(viewModel = vm, searchViewModel = searchVm) + PlannerScreen(viewModel = vm) } entry { val vm: RecipesViewModel = koinViewModel() - val searchVm: RecipesSearchViewModel = koinViewModel() - RecipesScreen(viewModel = vm, searchViewModel = searchVm) + RecipesScreen(viewModel = vm) } entry { val vm: PantryViewModel = koinViewModel() - val searchVm: PantrySearchViewModel = koinViewModel() - PantryScreen(viewModel = vm, searchViewModel = searchVm) + PantryScreen(viewModel = vm) } entry { val vm: ShoppingViewModel = koinViewModel() - val searchVm: ShoppingSearchViewModel = koinViewModel() - ShoppingScreen(viewModel = vm, searchViewModel = searchVm) + ShoppingScreen(viewModel = vm) } }, ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt index bbd0cdf..ccba32c 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt @@ -2,29 +2,44 @@ package dev.ulfrx.recipe.ui.components.dock import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable 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.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledIcon @@ -34,9 +49,11 @@ import com.composeunstyled.UnstyledTabList import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.search_close_a11y +import kotlin.math.roundToInt /** * Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180. @@ -105,34 +122,137 @@ fun DockBar( } } +/** + * Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so + * we can drive a `Modifier.offset { IntOffset(...) }` without re-converting + * each frame. + */ +private data class TabBounds( + val offsetXPx: Float, + val widthPx: Float, +) + @Composable private fun ExpandedDockTabs( destinations: List, active: BottomBarDestination, onTabSelect: (BottomBarDestination) -> Unit, ) { + val density = LocalDensity.current + + // Per-tab measured bounds, populated as each cell lays out. The floating + // pill follows the active tab's entry — when `active` flips, the pill + // animates from its current bounds to the new tab's bounds (Apple-Music- + // style sliding indicator). + val tabPositions = remember { mutableStateMapOf() } + + // Pill is rendered wider than the cell so the active tab visually + // dominates without resizing any other cell. The pill bleeds into the + // 2 dp inter-cell gap and slightly into adjacent cells; inactive icons + + // labels remain on top (z-order), readable above the dark substrate. + val pillExpansion = 8.dp + val pillExpansionPx = with(density) { pillExpansion.toPx() } + + val pillX = remember { Animatable(0f) } + val pillW = remember { Animatable(0f) } + // Pill animates only on `active` change — never per-frame. Two LaunchedEffects: + // - keyed on `tabPositions[active]`: handles the very first measurement + // (snap, so the pill is at the correct place on cold paint). + // - keyed on `active`: handles every subsequent tap (single 200 ms tween, + // no re-launch storm). Cells are uniform-weight so the target captured + // at click time stays valid for the full animation — nothing moves + // under the pill mid-flight. + var initialized by remember { mutableStateOf(false) } + + LaunchedEffect(tabPositions[active]) { + if (initialized) return@LaunchedEffect + val t = tabPositions[active] ?: return@LaunchedEffect + pillX.snapTo(t.offsetXPx - pillExpansionPx) + pillW.snapTo(t.widthPx + 2f * pillExpansionPx) + initialized = true + } + + LaunchedEffect(active) { + if (!initialized) return@LaunchedEffect + val t = tabPositions[active] ?: return@LaunchedEffect + launch { + pillX.animateTo( + targetValue = t.offsetXPx - pillExpansionPx, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + ) + } + launch { + pillW.animateTo( + targetValue = t.widthPx + 2f * pillExpansionPx, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + ) + } + } + UnstyledTabGroup( selectedTab = active.name, tabs = destinations.map { it.name }, modifier = Modifier.fillMaxSize(), ) { - UnstyledTabList( + Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = RecipeTheme.spacing.xs), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, + // sm (8 dp) inner padding gives the active pill room to + // expand up to 8 dp past its cell while still leaving the + // matching 4 dp gap to the dock's outer rounded edge on + // first / last tabs. + .padding(horizontal = RecipeTheme.spacing.sm), ) { - destinations.forEach { dest -> - val isActive = dest == active - DockTabCell( - destination = dest, - isActive = isActive, - onClick = { onTabSelect(dest) }, - modifier = Modifier.weight(1f), + // Floating pill (bottom z-layer). Inset 4dp vertical / 3dp + // horizontal from the measured cell bounds — same geometry as the + // previous per-cell pill, just rendered once and animated. + if (initialized) { + Box( + modifier = + Modifier + .offset { IntOffset(pillX.value.roundToInt(), 0) } + .width(with(density) { pillW.value.toDp() }) + .fillMaxHeight() + // 4dp on all sides — matches the dock's inner + // sm padding so an edge-tab pill has equal gap + // to the outer rounded edge top/bottom AND side. + .padding(4.dp) + .background( + Color.Black.copy(alpha = 0.3f), + RoundedCornerShape(50), + ), ) } + + // Tab row on top — icons + labels are drawn over the pill so the + // active tab's foreground (accent) reads against the dark inset. + UnstyledTabList( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + destinations.forEach { dest -> + DockTabCell( + destination = dest, + isActive = dest == active, + onClick = { onTabSelect(dest) }, + // Uniform weight — cells stay fixed during a tab + // switch. The active-feels-bigger emphasis is carried + // entirely by the dark pill behind the icon + label. + modifier = + Modifier + .weight(1f) + .onGloballyPositioned { coords -> + tabPositions[dest] = + TabBounds( + offsetXPx = coords.positionInParent().x, + widthPx = coords.size.width.toFloat(), + ) + }, + ) + } + } } } } @@ -145,17 +265,19 @@ private fun DockTabCell( modifier: Modifier = Modifier, ) { val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted - val pillColor = if (isActive) RecipeTheme.colors.accent.copy(alpha = 0.16f) else Color.Transparent val labelText = stringResource(destination.labelRes) val a11ySuffix = if (isActive) ", aktywna" else "" + // Cell is just the touch target + foreground (icon + label). The pill + // background lives in [ExpandedDockTabs] as a single sliding indicator, + // so individual cells stay transparent. UnstyledTab( key = destination.name, selected = isActive, onSelected = onClick, activateOnFocus = false, - shape = RoundedCornerShape(20.dp), - backgroundColor = pillColor, - contentPadding = PaddingValues(vertical = 6.dp), + shape = RoundedCornerShape(50), + backgroundColor = Color.Transparent, + contentPadding = PaddingValues(0.dp), modifier = modifier .fillMaxSize() 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 590fa13..24a76bc 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 @@ -35,8 +35,8 @@ fun FloatingSearchButton( onClick: () -> Unit = {}, ) { GlassSurface( - modifier = modifier.size(56.dp), - cornerRadius = 28.dp, + modifier = modifier.size(63.dp), + cornerRadius = 31.5.dp, ) { UnstyledButton( onClick = onClick, 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 index 2476b7d..fd3c1eb 100644 --- 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 @@ -8,167 +8,95 @@ 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.runtime.LaunchedEffect 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`). + * Bottom chrome rendered while shell-wide search is open (states B and C from + * [SearchState]). * - * 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. + * Layout decided from [SearchState.isFocused]: + * - **B (`isFocused=false`)** — `[ collapsed dock icon ] [ search pill ]`. + * Tapping the collapsed dock icon closes search and returns to [activeTab]. + * - **C (`isFocused=true`)** — `[ search pill (full width) ] [ X button ]`. + * The collapsed dock icon disappears (Apple Music pattern: the left affordance + * yields to the search context). Tapping X clears the query and unfocuses + * back to State B. * - * ## What it does + * Geometry mirrors the existing chrome: 45dp height, capsule shapes, + * [RecipeTheme.spacing.sm] gap between cells. * - * - 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`). + * Focus wiring is bi-directional: when [SearchPill]'s `BasicTextField` reports + * focus changes, [onFocusGained] / [onFocusLost] propagate them into the shell + * VM. When the VM commands `isFocused=false` (e.g. via X), a [LaunchedEffect] + * here drives `focusManager.clearFocus()` to flush the platform focus. */ @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( +fun SearchPillRow( query: String, + isFocused: Boolean, placeholder: String, activeTab: BottomBarDestination, onQueryChange: (String) -> Unit, onClose: () -> Unit, - onClear: () -> Unit, + onFocusGained: () -> Unit, + onFocusLost: () -> Unit, ) { - var focused by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current - val pillHeight = 45.dp + // Search pill / collapsed dock icon / X button — all share this height. + // AppShell vertically centres the SearchPillRow within the dock's 63dp + // band so the pill sits in the middle of the tab-bar position rather than + // nudged toward the top. + val pillHeight = 48.dp + + // VM-commanded unfocus → flush platform focus from BasicTextField. + LaunchedEffect(isFocused) { + if (!isFocused) focusManager.clearFocus() + } 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, - ) + if (!isFocused) { + DockBar( + destinations = BottomBarDestination.entries, + active = activeTab, + collapsed = true, + onTabSelect = { /* unreachable while collapsed */ }, + onCollapsedTap = onClose, + height = pillHeight, + ) + } SearchPill( query = query, onQueryChange = onQueryChange, - onFocusChanged = { focused = it }, + onFocusChanged = { focused -> + if (focused) onFocusGained() else onFocusLost() + }, placeholder = placeholder, modifier = Modifier.weight(1f), height = pillHeight, ) - if (focused) { + if (isFocused) { DismissSearchKeyboardButton( - onClick = { - onClear() - focusManager.clearFocus() - focused = false - }, + onClick = onFocusLost, size = pillHeight, ) } @@ -176,12 +104,8 @@ private fun SearchPillRow( } /** - * 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. + * 45dp circular Liquid-glass X button. Visible only in State C — tapping it + * unfocuses the search field and clears the query (returns to State B). */ @Composable private fun DismissSearchKeyboardButton( 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 index de58d8d..660f4a5 100644 --- 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 @@ -1,57 +1,25 @@ 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`. + * Shell-wide search state shape, exposed by + * [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel] as a hot + * `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). + * Three logical states (Apple Music pattern): + * - **A** — Closed: `isOpen=false`. Default tab is rendered; floating search + * button sits in the dock's trailing slot. + * - **B** — Open, unfocused: `isOpen=true, isFocused=false`. The SearchScreen + * is on stage with curated/quick-nav content; chrome shows collapsed dock + * icon + search pill (placeholder, no input). + * - **C** — Open, focused: `isOpen=true, isFocused=true`. Search input is + * active; chrome hides the collapsed dock icon and shows an X dismiss + * button on the right. + * + * `query` is the live input echo (results plumbing arrives once per-feature + * SearchSources exist in Phase 5/6/8/9). */ data class SearchState( val isOpen: Boolean = false, + val isFocused: 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 894a8ce..9860781 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,36 +16,24 @@ 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. + * Search is shell-wide; this screen owns no bottom-chrome state. */ @Composable -fun PantryScreen( - viewModel: PantryViewModel, - searchViewModel: PantrySearchViewModel, -) { +fun PantryScreen(viewModel: PantryViewModel) { @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 deleted file mode 100644 index c3e99df..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.pantry - -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 - -/** - * 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. - */ -class PantrySearchViewModel( - @Suppress("UNUSED_PARAMETER") - private val searchSource: SearchSource? = null, -) : ViewModel(), - SearchControls { - private val _state = MutableStateFlow(SearchState()) - override val state: StateFlow = _state.asStateFlow() - - override fun open() { - _state.update { it.copy(isOpen = true) } - } - - /** D-08: closing clears the query. */ - override fun close() { - _state.value = SearchState(isOpen = false, query = "") - } - - override fun onQueryChange(q: String) { - _state.update { it.copy(query = q) } - } - - /** D-07: clear() resets only the query, preserves isOpen. */ - override fun clear() { - _state.update { it.copy(query = "") } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt index 9676cc5..3f8e80a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt @@ -16,36 +16,24 @@ 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_planner_subtitle import recipe.composeapp.generated.resources.empty_planner_title -import recipe.composeapp.generated.resources.search_placeholder_planner import recipe.composeapp.generated.resources.shell_tab_planner /** * Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the * empty body with the calendar grid. * - * Owns its own bottom-bar chrome via [ProvideSearchChrome] — search affordance - * is shell-wide for visual consistency across tabs. + * Search is shell-wide; this screen owns no bottom-chrome state. */ @Composable -fun PlannerScreen( - viewModel: PlannerViewModel, - searchViewModel: PlannerSearchViewModel, -) { +fun PlannerScreen(viewModel: PlannerViewModel) { @Suppress("UNUSED_VARIABLE") val state by viewModel.state.collectAsStateWithLifecycle() - ProvideSearchChrome( - controls = searchViewModel, - placeholder = Res.string.search_placeholder_planner, - activeTab = BottomBarDestination.Planner, - ) - Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerSearchViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerSearchViewModel.kt deleted file mode 100644 index 827f6cc..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerSearchViewModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.planner - -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 - -/** - * PlannerSearchViewModel — semantic parity with the Recipes / Pantry search VMs. - * Pure echo this phase; Phase 6/7 injects a Planner-specific SearchSource. - */ -class PlannerSearchViewModel( - @Suppress("UNUSED_PARAMETER") - private val searchSource: SearchSource? = null, -) : ViewModel(), - SearchControls { - private val _state = MutableStateFlow(SearchState()) - override val state: StateFlow = _state.asStateFlow() - - override fun open() { - _state.update { it.copy(isOpen = true) } - } - - /** D-08: closing clears the query. */ - override fun close() { - _state.value = SearchState(isOpen = false, query = "") - } - - override fun onQueryChange(q: String) { - _state.update { it.copy(query = q) } - } - - /** D-07: clear() resets only the query, preserves isOpen. */ - 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 f496ffc..17fe394 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,39 +16,25 @@ 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. + * Search is now shell-wide (see `AppShell` + `ShellSearchViewModel`) — this + * screen no longer owns any bottom-chrome state. */ @Composable -fun RecipesScreen( - viewModel: RecipesViewModel, - searchViewModel: RecipesSearchViewModel, -) { +fun RecipesScreen(viewModel: RecipesViewModel) { @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 deleted file mode 100644 index 54a99db..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -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 - -/** - * 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(), - SearchControls { - private val _state = MutableStateFlow(SearchState()) - override val state: StateFlow = _state.asStateFlow() - - /** Open the search affordance. */ - override fun open() { - _state.update { it.copy(isOpen = true) } - } - - /** D-08: closing clears the query — reopening starts blank. */ - override fun close() { - _state.value = SearchState(isOpen = false, query = "") - } - - /** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */ - override fun onQueryChange(q: String) { - _state.update { it.copy(query = q) } - } - - /** D-07: clear() resets only the query and keeps isOpen=true. */ - override fun clear() { - _state.update { it.copy(query = "") } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt new file mode 100644 index 0000000..d369ccf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt @@ -0,0 +1,72 @@ +package dev.ulfrx.recipe.ui.screens.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Search +import dev.ulfrx.recipe.ui.components.empty.EmptyState +import dev.ulfrx.recipe.ui.theme.RecipeTheme +import org.jetbrains.compose.resources.stringResource +import recipe.composeapp.generated.resources.Res +import recipe.composeapp.generated.resources.search_screen_curated_subtitle +import recipe.composeapp.generated.resources.search_screen_curated_title +import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle +import recipe.composeapp.generated.resources.search_screen_empty_results_title + +/** + * Global search destination — overlays the active tab when + * [ShellSearchViewModel.state.isOpen] is true. Hosted by `AppShell`, not by the + * tab `NavDisplay`, so navigating in/out doesn't disturb per-tab back stacks. + * + * Two body modes driven by `state.isFocused`: + * - **B (unfocused)** — curated landing. v1 placeholder copy; later phases will + * surface recents, quick filters, and per-tab shortcuts here. + * - **C (focused)** — live search. v1 shows an empty-results hint until per- + * feature SearchSources are wired in Phase 5/6/8/9. + * + * The search input pill itself lives in the bottom chrome (see `SearchChrome.kt`), + * not on this screen — keeping the keyboard-adjacent affordance consistent with + * the rest of the shell. + */ +@Composable +fun SearchScreen(viewModel: ShellSearchViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Box( + modifier = + Modifier + .fillMaxSize() + .background(RecipeTheme.colors.background), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = RecipeTheme.spacing.xl), + ) { + if (state.isFocused) { + EmptyState( + icon = Lucide.Search, + title = stringResource(Res.string.search_screen_empty_results_title), + subtitle = stringResource(Res.string.search_screen_empty_results_subtitle), + ) + } else { + EmptyState( + icon = Lucide.Search, + title = stringResource(Res.string.search_screen_curated_title), + subtitle = stringResource(Res.string.search_screen_curated_subtitle), + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt new file mode 100644 index 0000000..8b93b45 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt @@ -0,0 +1,50 @@ +package dev.ulfrx.recipe.ui.screens.search + +import androidx.lifecycle.ViewModel +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 + +/** + * Single shell-wide search VM. Replaces the per-tab `…SearchViewModel`s — search + * is now a global affordance, not feature-scoped. + * + * Drives the three-state Apple-Music-style flow described in [SearchState]: + * - `open()` A → B (clicked the floating search button) + * - `focus()` B → C (tapped the search pill — text field gained focus) + * - `unfocus()` C → B (tapped the X dismiss button — clears query AND focus) + * - `close()` B/C → A (tapped the collapsed dock icon — returns to the + * originating tab; clears focus and query so a fresh open starts blank) + * - `onQueryChange(q)` — pure echo this phase; per-feature SearchSource + * plumbing arrives in Phase 5/6/8/9. + */ +class ShellSearchViewModel : ViewModel() { + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow = _state.asStateFlow() + + fun open() { + _state.update { it.copy(isOpen = true, isFocused = false) } + } + + fun close() { + _state.value = SearchState() + } + + fun focus() { + _state.update { if (it.isOpen) it.copy(isFocused = true) else it } + } + + fun unfocus() { + _state.update { it.copy(isFocused = false, query = "") } + } + + fun onQueryChange(q: String) { + _state.update { it.copy(query = q) } + } + + 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 8fb33a1..e59995c 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 @@ -2,6 +2,7 @@ package dev.ulfrx.recipe.ui.screens.shell import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -13,134 +14,174 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars 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.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.ulfrx.recipe.navigation.BottomBarDestination 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.dock.FloatingSearchButton import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource +import dev.ulfrx.recipe.ui.components.search.SearchPillRow +import dev.ulfrx.recipe.ui.screens.search.SearchScreen +import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel 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_placeholder /** - * Authenticated root composable. Hosts navigation and the bottom-bar chrome - * skeleton, but is **agnostic about feature concerns** like search. + * Authenticated root composable. Owns: + * - the per-tab navigation back stacks via [TabNavigator] + * - the shell-wide search affordance via [ShellSearchViewModel] * - * ## Layout - * - Background: full-screen [RecipeTheme.colors.background] under the safe area. - * - 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]). - * 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()`. + * ## Body modes (driven by `searchVm.state.isOpen`) * - * ## Active-tab tracking - * 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. + * - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom + * chrome is `[DockBar (full)] [FloatingSearchButton]`. + * - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom + * chrome is [SearchPillRow], whose layout shifts further on `isFocused` + * (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt). + * + * ## Back-press handling + * + * While search is open, a [BackHandler] consumes the back press as a no-op: + * the user must exit search explicitly via the collapsed dock icon (B→A) or X + * (C→B). Confirmed product decision — no implicit dismissal while in search. * * ## Why TabNavigator and not the AndroidX NavController - * Phase 2.1 originally wired the dock through a single Nav-2 `NavHost` with - * four nested `navigation<…>` sub-graphs using `popUpTo + saveState + - * restoreState` for multi-back-stack. Nav 3 replaces that with an app-owned - * back stack (a `SnapshotStateList`), so [TabNavigator] holds **one - * stack per tab** and [RootNavDisplay] renders only the active one inside a - * `NavDisplay`. The implementation closely mirrors the - * [To-Do-CMP reference](https://github.com/stevdza-san/To-Do-CMP) pattern — - * `Navigator` + `NavDisplay(entryProvider = …)` — extended for the dock's - * parallel-stack requirement. - * - * ## What used to live here, and why it moved - * The previous version of this file knew about `RecipesSearchViewModel` and - * `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. + * (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See + * [RootNavDisplay] for the full rationale.) */ +@OptIn(ExperimentalComposeUiApi::class) +@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the +// latter is overkill for a static "consume back" guard. Revisit when stable. @Preview @Composable fun AppShell(modifier: Modifier = Modifier) { val navigator = remember { TabNavigator() } + val searchVm: ShellSearchViewModel = koinViewModel() + val searchState by searchVm.state.collectAsStateWithLifecycle() - // Single chrome-state holder for the lifetime of the shell. Provided to - // descendants via LocalShellChrome. - val chrome = remember { ShellChromeState() } + BackHandler(enabled = searchState.isOpen) { + // Blocked — user must exit search via explicit affordance (dock icon or X). + } - // 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. - - CompositionLocalProvider(LocalShellChrome provides chrome) { - Box( - modifier = - modifier - .fillMaxSize() - .background(RecipeTheme.colors.background), - ) { - // 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()) { - RootNavDisplay( - navigator = navigator, - modifier = Modifier.fillMaxSize(), - ) + Box( + modifier = + modifier + .fillMaxSize() + .background(RecipeTheme.colors.background), + ) { + // Body — cross-fade between the tab stack and the search overlay. + GlassBackdropSource(modifier = Modifier.fillMaxSize()) { + AnimatedContent( + targetState = searchState.isOpen, + modifier = Modifier.fillMaxSize(), + transitionSpec = { + fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing)) + }, + label = "AppShell body", + ) { searchOpen -> + if (searchOpen) { + SearchScreen(viewModel = searchVm) + } else { + RootNavDisplay( + navigator = navigator, + modifier = Modifier.fillMaxSize(), + ) + } } + } - // 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)) - }, - 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 = navigator.activeTab, - onTabSelect = navigator::selectTab, - trailingSlot = chrome.trailingSlot, - ) - } + // Bottom chrome — Apple-Music-style: don't respect the full nav-bar + // inset (home indicator) for the bottom edge; halve it so chrome sits + // close to the bottom and the home indicator visually overlaps the + // chrome substrate. When IME is up, use the full IME inset (it's much + // larger than navInset/2, so `max` keeps the chrome above the keyboard). + val bottomInset = + with(LocalDensity.current) { + val imePx = WindowInsets.ime.getBottom(this) + val navPx = WindowInsets.navigationBars.getBottom(this) + maxOf(imePx, navPx / 2).toDp() + } + // Horizontal chrome padding animates with the search state: + // - Closed (dock visible) → xl (24 dp) + // - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly + // inset from the dock's footprint + // - Open, focused (search C) → 8 dp, so the input reads as a width + // extension of the keyboard above it + val horizontalPadding by animateDpAsState( + targetValue = + when { + !searchState.isOpen -> RecipeTheme.spacing.xl + !searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp + else -> 8.dp + }, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "chrome horizontal padding", + ) + Row( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = RecipeTheme.spacing.sm, + bottom = bottomInset + RecipeTheme.spacing.xs, + ), + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = searchState.isOpen, + // Lock chrome region to the dock's height in both modes so + // (a) the body above doesn't shift when search opens / closes, + // and (b) the (shorter) search pill is centred vertically + // inside the same band the dock occupies. + modifier = Modifier.fillMaxWidth().height(63.dp), + contentAlignment = Alignment.Center, + transitionSpec = { + fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing)) + }, + label = "AppShell bottom chrome", + ) { searchOpen -> + if (searchOpen) { + SearchPillRow( + query = searchState.query, + isFocused = searchState.isFocused, + placeholder = stringResource(Res.string.search_placeholder), + activeTab = navigator.activeTab, + onQueryChange = searchVm::onQueryChange, + onClose = searchVm::close, + onFocusGained = searchVm::focus, + onFocusLost = searchVm::unfocus, + ) + } else { + DefaultDockRow( + activeTab = navigator.activeTab, + onTabSelect = navigator::selectTab, + onSearchTap = searchVm::open, + ) } } } @@ -151,7 +192,7 @@ fun AppShell(modifier: Modifier = Modifier) { private fun DefaultDockRow( activeTab: BottomBarDestination, onTabSelect: (BottomBarDestination) -> Unit, - trailingSlot: (@Composable () -> Unit)?, + onSearchTap: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -165,10 +206,10 @@ private fun DefaultDockRow( onTabSelect = onTabSelect, onCollapsedTap = { /* unreachable in default mode */ }, modifier = Modifier.weight(1f), - height = 56.dp, + height = 63.dp, ) - Box(modifier = Modifier.size(56.dp)) { - trailingSlot?.invoke() + Box(modifier = Modifier.size(63.dp)) { + FloatingSearchButton(onClick = onSearchTap) } } } 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 deleted file mode 100644 index 818ece3..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChrome.kt +++ /dev/null @@ -1,53 +0,0 @@ -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/shopping/ShoppingScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt index 71825ac..e6ed1cf 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt @@ -16,36 +16,24 @@ 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_shopping_subtitle import recipe.composeapp.generated.resources.empty_shopping_title -import recipe.composeapp.generated.resources.search_placeholder_shopping import recipe.composeapp.generated.resources.shell_tab_shopping /** * Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the * empty body with the shopping list + session UI. * - * Owns its own bottom-bar chrome via [ProvideSearchChrome] — search affordance - * is shell-wide for visual consistency across tabs. + * Search is shell-wide; this screen owns no bottom-chrome state. */ @Composable -fun ShoppingScreen( - viewModel: ShoppingViewModel, - searchViewModel: ShoppingSearchViewModel, -) { +fun ShoppingScreen(viewModel: ShoppingViewModel) { @Suppress("UNUSED_VARIABLE") val state by viewModel.state.collectAsStateWithLifecycle() - ProvideSearchChrome( - controls = searchViewModel, - placeholder = Res.string.search_placeholder_shopping, - activeTab = BottomBarDestination.Shopping, - ) - Box( modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), ) { diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingSearchViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingSearchViewModel.kt deleted file mode 100644 index e2b65e6..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingSearchViewModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.shopping - -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 - -/** - * ShoppingSearchViewModel — semantic parity with the Recipes / Pantry search VMs. - * Pure echo this phase; Phase 9 injects a Shopping-specific SearchSource. - */ -class ShoppingSearchViewModel( - @Suppress("UNUSED_PARAMETER") - private val searchSource: SearchSource? = null, -) : ViewModel(), - SearchControls { - private val _state = MutableStateFlow(SearchState()) - override val state: StateFlow = _state.asStateFlow() - - override fun open() { - _state.update { it.copy(isOpen = true) } - } - - /** D-08: closing clears the query. */ - override fun close() { - _state.value = SearchState(isOpen = false, query = "") - } - - override fun onQueryChange(q: String) { - _state.update { it.copy(query = q) } - } - - /** D-07: clear() resets only the query, preserves isOpen. */ - override fun clear() { - _state.update { it.copy(query = "") } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f189da9..900cb2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }