Adjust menu size and style
This commit is contained in:
@@ -77,6 +77,7 @@ kotlin {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.backhandler)
|
||||
implementation(libs.compose.components.resources)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { ... }")
|
||||
}
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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 = "") }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user