Adjust menu size and style

This commit is contained in:
2026-05-13 18:09:50 +02:00
parent 4a9cba02d6
commit 3296349507
22 changed files with 504 additions and 612 deletions

View File

@@ -77,6 +77,7 @@ kotlin {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.backhandler)
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)

View File

@@ -19,11 +19,14 @@
<string name="shell_tab_pantry">Spiżarnia</string> <string name="shell_tab_pantry">Spiżarnia</string>
<string name="shell_tab_shopping">Zakupy</string> <string name="shell_tab_shopping">Zakupy</string>
<!-- Phase 2.1 — Search affordance placeholders (UI-10, CONTEXT D-06) --> <!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
<string name="search_placeholder_recipes">Szukaj przepisów</string> <string name="search_placeholder">Szukaj…</string>
<string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
<string name="search_placeholder_planner">Szukaj w planie…</string> <!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; results in State C) -->
<string name="search_placeholder_shopping">Szukaj na liście…</string> <string name="search_screen_curated_title">Tu pojawią się szybkie skróty</string>
<string name="search_screen_curated_subtitle">Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji.</string>
<string name="search_screen_empty_results_title">Brak wyników</string>
<string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string>
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) --> <!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string> <string name="search_open_a11y">Otwórz wyszukiwanie</string>

View File

@@ -1,12 +1,9 @@
package dev.ulfrx.recipe.di 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.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerSearchViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel 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.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 dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel import org.koin.plugin.module.dsl.viewModel
@@ -22,11 +19,9 @@ val shellModule =
viewModel<PantryViewModel>() viewModel<PantryViewModel>()
viewModel<ShoppingViewModel>() viewModel<ShoppingViewModel>()
// Per-tab Search ViewModels — pure echo this phase; Phase 5 / 6 / 8 / 9 // Shell-wide search VM — single global state machine (closed / open
// inject their respective SearchSource implementations. All implement // unfocused / open focused) shared by the SearchScreen body and the
// SearchControls so the shared ProvideSearchChrome composable drives them. // SearchPillRow chrome. Per-tab SearchViewModels were retired when search
viewModel<RecipesSearchViewModel>() // moved from per-tab inline overlay to a shell-level destination.
viewModel<PantrySearchViewModel>() viewModel<ShellSearchViewModel>()
viewModel<PlannerSearchViewModel>()
viewModel<ShoppingSearchViewModel>()
} }

View File

@@ -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]) * 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. * so the shell's [TabNavigator] knows where each tab's back stack starts.
* *
* Per-tab contextual chrome (search button on Recipes / Pantry, future filter * Search is a shell-wide affordance (see
* buttons elsewhere) is owned by each screen via the slot pattern in * [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) — it lives outside
* [dev.ulfrx.recipe.ui.screens.shell.ShellChromeState]. This enum is therefore * the tab destinations entirely. This enum is intentionally minimal: route +
* intentionally minimal: route + label + icon, nothing about feature affordances. * label + icon, nothing about feature affordances.
*/ */
enum class BottomBarDestination( enum class BottomBarDestination(
val startDestination: Screen, val startDestination: Screen,

View File

@@ -11,16 +11,12 @@ import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen 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.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen 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.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen 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.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen 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 dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.compose.viewmodel.koinViewModel 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 * Phase 5+ introduces detail screens with their own VM scopes; at that point
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries * we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
* specifically (passed via `entryDecorators = listOf(...)`). * 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 @Composable
fun RootNavDisplay( fun RootNavDisplay(
@@ -74,23 +74,19 @@ fun RootNavDisplay(
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Planner.Home> { entry<Screen.Planner.Home> {
val vm: PlannerViewModel = koinViewModel() val vm: PlannerViewModel = koinViewModel()
val searchVm: PlannerSearchViewModel = koinViewModel() PlannerScreen(viewModel = vm)
PlannerScreen(viewModel = vm, searchViewModel = searchVm)
} }
entry<Screen.Recipes.Home> { entry<Screen.Recipes.Home> {
val vm: RecipesViewModel = koinViewModel() val vm: RecipesViewModel = koinViewModel()
val searchVm: RecipesSearchViewModel = koinViewModel() RecipesScreen(viewModel = vm)
RecipesScreen(viewModel = vm, searchViewModel = searchVm)
} }
entry<Screen.Pantry.Home> { entry<Screen.Pantry.Home> {
val vm: PantryViewModel = koinViewModel() val vm: PantryViewModel = koinViewModel()
val searchVm: PantrySearchViewModel = koinViewModel() PantryScreen(viewModel = vm)
PantryScreen(viewModel = vm, searchViewModel = searchVm)
} }
entry<Screen.Shopping.Home> { entry<Screen.Shopping.Home> {
val vm: ShoppingViewModel = koinViewModel() val vm: ShoppingViewModel = koinViewModel()
val searchVm: ShoppingSearchViewModel = koinViewModel() ShoppingScreen(viewModel = vm)
ShoppingScreen(viewModel = vm, searchViewModel = searchVm)
} }
}, },
) )

View File

@@ -2,29 +2,44 @@ package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledIcon
@@ -34,9 +49,11 @@ import com.composeunstyled.UnstyledTabList
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_close_a11y 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. * Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
@@ -105,33 +122,136 @@ 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 @Composable
private fun ExpandedDockTabs( private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>, destinations: List<BottomBarDestination>,
active: BottomBarDestination, active: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit, 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<BottomBarDestination, TabBounds>() }
// 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( UnstyledTabGroup(
selectedTab = active.name, selectedTab = active.name,
tabs = destinations.map { it.name }, tabs = destinations.map { it.name },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
UnstyledTabList( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = RecipeTheme.spacing.xs), // 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),
) {
// 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), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
destinations.forEach { dest -> destinations.forEach { dest ->
val isActive = dest == active
DockTabCell( DockTabCell(
destination = dest, destination = dest,
isActive = isActive, isActive = dest == active,
onClick = { onTabSelect(dest) }, onClick = { onTabSelect(dest) },
modifier = Modifier.weight(1f), // 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, modifier: Modifier = Modifier,
) { ) {
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted 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 labelText = stringResource(destination.labelRes)
val a11ySuffix = if (isActive) ", aktywna" else "" 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( UnstyledTab(
key = destination.name, key = destination.name,
selected = isActive, selected = isActive,
onSelected = onClick, onSelected = onClick,
activateOnFocus = false, activateOnFocus = false,
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(50),
backgroundColor = pillColor, backgroundColor = Color.Transparent,
contentPadding = PaddingValues(vertical = 6.dp), contentPadding = PaddingValues(0.dp),
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -35,8 +35,8 @@ fun FloatingSearchButton(
onClick: () -> Unit = {}, onClick: () -> Unit = {},
) { ) {
GlassSurface( GlassSurface(
modifier = modifier.size(56.dp), modifier = modifier.size(63.dp),
cornerRadius = 28.dp, cornerRadius = 31.5.dp,
) { ) {
UnstyledButton( UnstyledButton(
onClick = onClick, onClick = onClick,

View File

@@ -8,144 +8,73 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.Lucide
import com.composables.icons.lucide.X import com.composables.icons.lucide.X
import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar 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.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.screens.shell.LocalShellChrome
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y
/** /**
* Wires a feature's [SearchControls] VM into the shell's bottom-bar slots * Bottom chrome rendered while shell-wide search is open (states B and C from
* (`LocalShellChrome.trailingSlot` and `LocalShellChrome.bottomOverlay`). * [SearchState]).
* *
* Call this once from any feature screen that wants the shared search affordance * Layout decided from [SearchState.isFocused]:
* (Recipes, Pantry, …). The shell does not need to know which features have search; * - **B (`isFocused=false`)** — `[ collapsed dock icon ] [ search pill ]`.
* it just renders whatever slots the active screen has supplied. * 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] * Focus wiring is bi-directional: when [SearchPill]'s `BasicTextField` reports
* that calls `controls.open()`. Bottom overlay stays null (default DockBar visible). * focus changes, [onFocusGained] / [onFocusLost] propagate them into the shell
* * VM. When the VM commands `isFocused=false` (e.g. via X), a [LaunchedEffect]
* - When `controls.state.isOpen == true`: bottom overlay takes over the row, * here drives `focusManager.clearFocus()` to flush the platform focus.
* 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 @Composable
fun ProvideSearchChrome( fun SearchPillRow(
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, query: String,
isFocused: Boolean,
placeholder: String, placeholder: String,
activeTab: BottomBarDestination, activeTab: BottomBarDestination,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
onClear: () -> Unit, onFocusGained: () -> Unit,
onFocusLost: () -> Unit,
) { ) {
var focused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (!isFocused) {
DockBar( DockBar(
destinations = BottomBarDestination.entries, destinations = BottomBarDestination.entries,
active = activeTab, active = activeTab,
@@ -154,21 +83,20 @@ private fun SearchPillRow(
onCollapsedTap = onClose, onCollapsedTap = onClose,
height = pillHeight, height = pillHeight,
) )
}
SearchPill( SearchPill(
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onFocusChanged = { focused = it }, onFocusChanged = { focused ->
if (focused) onFocusGained() else onFocusLost()
},
placeholder = placeholder, placeholder = placeholder,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
height = pillHeight, height = pillHeight,
) )
if (focused) { if (isFocused) {
DismissSearchKeyboardButton( DismissSearchKeyboardButton(
onClick = { onClick = onFocusLost,
onClear()
focusManager.clearFocus()
focused = false
},
size = pillHeight, size = pillHeight,
) )
} }
@@ -176,12 +104,8 @@ private fun SearchPillRow(
} }
/** /**
* 45dp circular Liquid-glass button that clears the active query and dismisses * 45dp circular Liquid-glass X button. Visible only in State C — tapping it
* the keyboard. Visible only while the [SearchPill] field is focused. * unfocuses the search field and clears the query (returns to State B).
*
* 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 @Composable
private fun DismissSearchKeyboardButton( private fun DismissSearchKeyboardButton(

View File

@@ -1,57 +1,25 @@
package dev.ulfrx.recipe.ui.components.search 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] * Shell-wide search state shape, exposed by
* and [dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel] expose this via * [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel] as a hot
* their `state: StateFlow<SearchState>`. * `StateFlow<SearchState>`.
* *
* - [isOpen] — whether the search affordance is open on this tab. * Three logical states (Apple Music pattern):
* - [query] — the current query echo (D-07: just an echo this phase; results * - **A** — Closed: `isOpen=false`. Default tab is rendered; floating search
* plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively). * 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( data class SearchState(
val isOpen: Boolean = false, val isOpen: Boolean = false,
val isFocused: Boolean = false,
val query: String = "", 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<List<*>>
}
/**
* 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<SearchState>
/** 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()
}

View File

@@ -16,36 +16,24 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
import dev.ulfrx.recipe.ui.theme.RecipeTheme 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.Res
import recipe.composeapp.generated.resources.empty_pantry_subtitle import recipe.composeapp.generated.resources.empty_pantry_subtitle
import recipe.composeapp.generated.resources.empty_pantry_title import recipe.composeapp.generated.resources.empty_pantry_title
import recipe.composeapp.generated.resources.search_placeholder_pantry
import recipe.composeapp.generated.resources.shell_tab_pantry import recipe.composeapp.generated.resources.shell_tab_pantry
/** /**
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the * Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
* empty body with the inventory list. * empty body with the inventory list.
* *
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not * Search is shell-wide; this screen owns no bottom-chrome state.
* know that Pantry has a search button.
*/ */
@Composable @Composable
fun PantryScreen( fun PantryScreen(viewModel: PantryViewModel) {
viewModel: PantryViewModel,
searchViewModel: PantrySearchViewModel,
) {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
ProvideSearchChrome(
controls = searchViewModel,
placeholder = Res.string.search_placeholder_pantry,
activeTab = BottomBarDestination.Pantry,
)
Box( Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) { ) {

View File

@@ -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<SearchState> = _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 = "") }
}
}

View File

@@ -16,36 +16,24 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
import dev.ulfrx.recipe.ui.theme.RecipeTheme 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.Res
import recipe.composeapp.generated.resources.empty_planner_subtitle import recipe.composeapp.generated.resources.empty_planner_subtitle
import recipe.composeapp.generated.resources.empty_planner_title import recipe.composeapp.generated.resources.empty_planner_title
import recipe.composeapp.generated.resources.search_placeholder_planner
import recipe.composeapp.generated.resources.shell_tab_planner import recipe.composeapp.generated.resources.shell_tab_planner
/** /**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the * Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
* empty body with the calendar grid. * empty body with the calendar grid.
* *
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — search affordance * Search is shell-wide; this screen owns no bottom-chrome state.
* is shell-wide for visual consistency across tabs.
*/ */
@Composable @Composable
fun PlannerScreen( fun PlannerScreen(viewModel: PlannerViewModel) {
viewModel: PlannerViewModel,
searchViewModel: PlannerSearchViewModel,
) {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
ProvideSearchChrome(
controls = searchViewModel,
placeholder = Res.string.search_placeholder_planner,
activeTab = BottomBarDestination.Planner,
)
Box( Box(
modifier = modifier =
Modifier Modifier

View File

@@ -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<SearchState> = _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 = "") }
}
}

View File

@@ -16,39 +16,25 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
import dev.ulfrx.recipe.ui.theme.RecipeTheme 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.Res
import recipe.composeapp.generated.resources.empty_recipes_subtitle import recipe.composeapp.generated.resources.empty_recipes_subtitle
import recipe.composeapp.generated.resources.empty_recipes_title import recipe.composeapp.generated.resources.empty_recipes_title
import recipe.composeapp.generated.resources.search_placeholder_recipes
import recipe.composeapp.generated.resources.shell_tab_recipes import recipe.composeapp.generated.resources.shell_tab_recipes
/** /**
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the * Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
* empty body with the recipe catalog grid. * empty body with the recipe catalog grid.
* *
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not * Search is now shell-wide (see `AppShell` + `ShellSearchViewModel`) — this
* know that Recipes has a search button. When this screen leaves composition * screen no longer owns any bottom-chrome state.
* (tab switch), the chrome slots clear themselves automatically.
*/ */
@Composable @Composable
fun RecipesScreen( fun RecipesScreen(viewModel: RecipesViewModel) {
viewModel: RecipesViewModel,
searchViewModel: RecipesSearchViewModel,
) {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle() 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( Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) { ) {

View File

@@ -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<SearchState> = _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 = "") }
}
}

View File

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

View File

@@ -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<SearchState> = _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 = "") }
}
}

View File

@@ -2,6 +2,7 @@ package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -13,145 +14,185 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.RootNavDisplay import dev.ulfrx.recipe.navigation.RootNavDisplay
import dev.ulfrx.recipe.navigation.TabNavigator import dev.ulfrx.recipe.navigation.TabNavigator
import dev.ulfrx.recipe.ui.components.dock.DockBar 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.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 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 * Authenticated root composable. Owns:
* skeleton, but is **agnostic about feature concerns** like search. * - the per-tab navigation back stacks via [TabNavigator]
* - the shell-wide search affordance via [ShellSearchViewModel]
* *
* ## Layout * ## Body modes (driven by `searchVm.state.isOpen`)
* - 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()`.
* *
* ## Active-tab tracking * - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom
* Read directly from [TabNavigator.activeTab], which is `MutableState<…>` so * chrome is `[DockBar (full)] [FloatingSearchButton]`.
* recomposition is automatic on tab switch. No mirror state needed — the * - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom
* navigator is the single source of truth. * 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 * ## Why TabNavigator and not the AndroidX NavController
* Phase 2.1 originally wired the dock through a single Nav-2 `NavHost` with * (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See
* four nested `navigation<…>` sub-graphs using `popUpTo + saveState + * [RootNavDisplay] for the full rationale.)
* restoreState` for multi-back-stack. Nav 3 replaces that with an app-owned
* back stack (a `SnapshotStateList<NavKey>`), so [TabNavigator] holds **one
* stack per tab** and [RootNavDisplay] renders only the active one inside a
* `NavDisplay`. The implementation closely mirrors the
* [To-Do-CMP reference](https://github.com/stevdza-san/To-Do-CMP) pattern —
* `Navigator` + `NavDisplay(entryProvider = …)` — extended for the dock's
* parallel-stack requirement.
*
* ## What used to live here, and why it moved
* The previous version of this file knew about `RecipesSearchViewModel` and
* `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.
*/ */
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the
// latter is overkill for a static "consume back" guard. Revisit when stable.
@Preview @Preview
@Composable @Composable
fun AppShell(modifier: Modifier = Modifier) { fun AppShell(modifier: Modifier = Modifier) {
val navigator = remember { TabNavigator() } 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 BackHandler(enabled = searchState.isOpen) {
// descendants via LocalShellChrome. // Blocked — user must exit search via explicit affordance (dock icon or X).
val chrome = remember { ShellChromeState() } }
// 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( Box(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.background(RecipeTheme.colors.background), .background(RecipeTheme.colors.background),
) { ) {
// Body — RootNavDisplay fills the available space and is the shared source // Body — cross-fade between the tab stack and the search overlay.
// layer for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
GlassBackdropSource(modifier = Modifier.fillMaxSize()) { 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( RootNavDisplay(
navigator = navigator, navigator = navigator,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
}
}
// Bottom chrome — one Row, two layout modes (default / overlay) chosen // Bottom chrome — Apple-Music-style: don't respect the full nav-bar
// by AnimatedContent for a clean cross-fade. // 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( Row(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.imePadding()
.padding( .padding(
horizontal = RecipeTheme.spacing.lg, start = horizontalPadding,
vertical = RecipeTheme.spacing.sm, end = horizontalPadding,
top = RecipeTheme.spacing.sm,
bottom = bottomInset + RecipeTheme.spacing.xs,
), ),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val overlay = chrome.bottomOverlay
AnimatedContent( AnimatedContent(
targetState = overlay != null, targetState = searchState.isOpen,
modifier = Modifier.fillMaxWidth(), // 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 = { transitionSpec = {
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing)) fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
}, },
label = "AppShell bottom chrome", label = "AppShell bottom chrome",
) { useOverlay -> ) { searchOpen ->
if (useOverlay) { if (searchOpen) {
// Re-read current overlay inside this branch — chrome state SearchPillRow(
// can change after the targetState was captured. query = searchState.query,
chrome.bottomOverlay?.invoke() 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 { } else {
DefaultDockRow( DefaultDockRow(
activeTab = navigator.activeTab, activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab, onTabSelect = navigator::selectTab,
trailingSlot = chrome.trailingSlot, onSearchTap = searchVm::open,
) )
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun DefaultDockRow( private fun DefaultDockRow(
activeTab: BottomBarDestination, activeTab: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit, onTabSelect: (BottomBarDestination) -> Unit,
trailingSlot: (@Composable () -> Unit)?, onSearchTap: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -165,10 +206,10 @@ private fun DefaultDockRow(
onTabSelect = onTabSelect, onTabSelect = onTabSelect,
onCollapsedTap = { /* unreachable in default mode */ }, onCollapsedTap = { /* unreachable in default mode */ },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
height = 56.dp, height = 63.dp,
) )
Box(modifier = Modifier.size(56.dp)) { Box(modifier = Modifier.size(63.dp)) {
trailingSlot?.invoke() FloatingSearchButton(onClick = onSearchTap)
} }
} }
} }

View File

@@ -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<ShellChromeState> {
error("ShellChromeState not provided — wrap content in AppShell { ... }")
}

View File

@@ -16,36 +16,24 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
import dev.ulfrx.recipe.ui.theme.RecipeTheme 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.Res
import recipe.composeapp.generated.resources.empty_shopping_subtitle import recipe.composeapp.generated.resources.empty_shopping_subtitle
import recipe.composeapp.generated.resources.empty_shopping_title import recipe.composeapp.generated.resources.empty_shopping_title
import recipe.composeapp.generated.resources.search_placeholder_shopping
import recipe.composeapp.generated.resources.shell_tab_shopping import recipe.composeapp.generated.resources.shell_tab_shopping
/** /**
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the * Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
* empty body with the shopping list + session UI. * empty body with the shopping list + session UI.
* *
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — search affordance * Search is shell-wide; this screen owns no bottom-chrome state.
* is shell-wide for visual consistency across tabs.
*/ */
@Composable @Composable
fun ShoppingScreen( fun ShoppingScreen(viewModel: ShoppingViewModel) {
viewModel: ShoppingViewModel,
searchViewModel: ShoppingSearchViewModel,
) {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
ProvideSearchChrome(
controls = searchViewModel,
placeholder = Res.string.search_placeholder_shopping,
activeTab = BottomBarDestination.Shopping,
)
Box( Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background), modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) { ) {

View File

@@ -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<SearchState> = _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 = "") }
}
}

View File

@@ -40,6 +40,7 @@ androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", 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 = { 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-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" } 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" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }