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

@@ -19,11 +19,14 @@
<string name="shell_tab_pantry">Spiżarnia</string>
<string name="shell_tab_shopping">Zakupy</string>
<!-- Phase 2.1 — Search affordance placeholders (UI-10, CONTEXT D-06) -->
<string name="search_placeholder_recipes">Szukaj przepisów</string>
<string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
<string name="search_placeholder_planner">Szukaj w planie…</string>
<string name="search_placeholder_shopping">Szukaj na liście…</string>
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
<string name="search_placeholder">Szukaj…</string>
<!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; results in State C) -->
<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) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string>

View File

@@ -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<PantryViewModel>()
viewModel<ShoppingViewModel>()
// 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<RecipesSearchViewModel>()
viewModel<PantrySearchViewModel>()
viewModel<PlannerSearchViewModel>()
viewModel<ShoppingSearchViewModel>()
// 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<ShellSearchViewModel>()
}

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])
* 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,

View File

@@ -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<Screen.Planner.Home> {
val vm: PlannerViewModel = koinViewModel()
val searchVm: PlannerSearchViewModel = koinViewModel()
PlannerScreen(viewModel = vm, searchViewModel = searchVm)
PlannerScreen(viewModel = vm)
}
entry<Screen.Recipes.Home> {
val vm: RecipesViewModel = koinViewModel()
val searchVm: RecipesSearchViewModel = koinViewModel()
RecipesScreen(viewModel = vm, searchViewModel = searchVm)
RecipesScreen(viewModel = vm)
}
entry<Screen.Pantry.Home> {
val vm: PantryViewModel = koinViewModel()
val searchVm: PantrySearchViewModel = koinViewModel()
PantryScreen(viewModel = vm, searchViewModel = searchVm)
PantryScreen(viewModel = vm)
}
entry<Screen.Shopping.Home> {
val vm: ShoppingViewModel = koinViewModel()
val searchVm: ShoppingSearchViewModel = koinViewModel()
ShoppingScreen(viewModel = vm, searchViewModel = searchVm)
ShoppingScreen(viewModel = vm)
}
},
)

View File

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

View File

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

View File

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

View File

@@ -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<SearchState>`.
* Shell-wide search state shape, exposed by
* [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel] as a hot
* `StateFlow<SearchState>`.
*
* - [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<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 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),
) {

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

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

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.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<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.
* (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)
}
}
}

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

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