Adjust menu size and style
This commit is contained in:
@@ -77,6 +77,7 @@ kotlin {
|
|||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.backhandler)
|
||||||
implementation(libs.compose.components.resources)
|
implementation(libs.compose.components.resources)
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
|
|||||||
@@ -19,11 +19,14 @@
|
|||||||
<string name="shell_tab_pantry">Spiżarnia</string>
|
<string name="shell_tab_pantry">Spiżarnia</string>
|
||||||
<string name="shell_tab_shopping">Zakupy</string>
|
<string name="shell_tab_shopping">Zakupy</string>
|
||||||
|
|
||||||
<!-- Phase 2.1 — Search affordance placeholders (UI-10, CONTEXT D-06) -->
|
<!-- Phase 2.1 — Global search placeholder (UI-10, CONTEXT D-06) -->
|
||||||
<string name="search_placeholder_recipes">Szukaj przepisów…</string>
|
<string name="search_placeholder">Szukaj…</string>
|
||||||
<string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
|
|
||||||
<string name="search_placeholder_planner">Szukaj w planie…</string>
|
<!-- Phase 2.1 — Search screen scaffolding (curated landing in State B; results in State C) -->
|
||||||
<string name="search_placeholder_shopping">Szukaj na liście…</string>
|
<string name="search_screen_curated_title">Tu pojawią się szybkie skróty</string>
|
||||||
|
<string name="search_screen_curated_subtitle">Sugestie wyszukiwania pojawią się wraz z rozwojem aplikacji.</string>
|
||||||
|
<string name="search_screen_empty_results_title">Brak wyników</string>
|
||||||
|
<string name="search_screen_empty_results_subtitle">Zacznij pisać, aby wyszukać.</string>
|
||||||
|
|
||||||
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||||
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package dev.ulfrx.recipe.di
|
package dev.ulfrx.recipe.di
|
||||||
|
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerSearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
import org.koin.plugin.module.dsl.viewModel
|
||||||
@@ -22,11 +19,9 @@ val shellModule =
|
|||||||
viewModel<PantryViewModel>()
|
viewModel<PantryViewModel>()
|
||||||
viewModel<ShoppingViewModel>()
|
viewModel<ShoppingViewModel>()
|
||||||
|
|
||||||
// Per-tab Search ViewModels — pure echo this phase; Phase 5 / 6 / 8 / 9
|
// Shell-wide search VM — single global state machine (closed / open
|
||||||
// inject their respective SearchSource implementations. All implement
|
// unfocused / open focused) shared by the SearchScreen body and the
|
||||||
// SearchControls so the shared ProvideSearchChrome composable drives them.
|
// SearchPillRow chrome. Per-tab SearchViewModels were retired when search
|
||||||
viewModel<RecipesSearchViewModel>()
|
// moved from per-tab inline overlay to a shell-level destination.
|
||||||
viewModel<PantrySearchViewModel>()
|
viewModel<ShellSearchViewModel>()
|
||||||
viewModel<PlannerSearchViewModel>()
|
|
||||||
viewModel<ShoppingSearchViewModel>()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import recipe.composeapp.generated.resources.shell_tab_shopping
|
|||||||
* Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home])
|
* Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home])
|
||||||
* so the shell's [TabNavigator] knows where each tab's back stack starts.
|
* so the shell's [TabNavigator] knows where each tab's back stack starts.
|
||||||
*
|
*
|
||||||
* Per-tab contextual chrome (search button on Recipes / Pantry, future filter
|
* Search is a shell-wide affordance (see
|
||||||
* buttons elsewhere) is owned by each screen via the slot pattern in
|
* [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) — it lives outside
|
||||||
* [dev.ulfrx.recipe.ui.screens.shell.ShellChromeState]. This enum is therefore
|
* the tab destinations entirely. This enum is intentionally minimal: route +
|
||||||
* intentionally minimal: route + label + icon, nothing about feature affordances.
|
* label + icon, nothing about feature affordances.
|
||||||
*/
|
*/
|
||||||
enum class BottomBarDestination(
|
enum class BottomBarDestination(
|
||||||
val startDestination: Screen,
|
val startDestination: Screen,
|
||||||
|
|||||||
@@ -11,16 +11,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerSearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingSearchViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@@ -52,6 +48,10 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
* Phase 5+ introduces detail screens with their own VM scopes; at that point
|
* Phase 5+ introduces detail screens with their own VM scopes; at that point
|
||||||
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
|
* we'll add `rememberViewModelStoreNavEntryDecorator()` for **detail** entries
|
||||||
* specifically (passed via `entryDecorators = listOf(...)`).
|
* specifically (passed via `entryDecorators = listOf(...)`).
|
||||||
|
*
|
||||||
|
* ## Search note
|
||||||
|
* Search is a shell-wide overlay (see `AppShell` + `ShellSearchViewModel`), not
|
||||||
|
* a tab destination — it lives outside this NavDisplay entirely.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RootNavDisplay(
|
fun RootNavDisplay(
|
||||||
@@ -74,23 +74,19 @@ fun RootNavDisplay(
|
|||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Planner.Home> {
|
entry<Screen.Planner.Home> {
|
||||||
val vm: PlannerViewModel = koinViewModel()
|
val vm: PlannerViewModel = koinViewModel()
|
||||||
val searchVm: PlannerSearchViewModel = koinViewModel()
|
PlannerScreen(viewModel = vm)
|
||||||
PlannerScreen(viewModel = vm, searchViewModel = searchVm)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Recipes.Home> {
|
entry<Screen.Recipes.Home> {
|
||||||
val vm: RecipesViewModel = koinViewModel()
|
val vm: RecipesViewModel = koinViewModel()
|
||||||
val searchVm: RecipesSearchViewModel = koinViewModel()
|
RecipesScreen(viewModel = vm)
|
||||||
RecipesScreen(viewModel = vm, searchViewModel = searchVm)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Pantry.Home> {
|
entry<Screen.Pantry.Home> {
|
||||||
val vm: PantryViewModel = koinViewModel()
|
val vm: PantryViewModel = koinViewModel()
|
||||||
val searchVm: PantrySearchViewModel = koinViewModel()
|
PantryScreen(viewModel = vm)
|
||||||
PantryScreen(viewModel = vm, searchViewModel = searchVm)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Shopping.Home> {
|
entry<Screen.Shopping.Home> {
|
||||||
val vm: ShoppingViewModel = koinViewModel()
|
val vm: ShoppingViewModel = koinViewModel()
|
||||||
val searchVm: ShoppingSearchViewModel = koinViewModel()
|
ShoppingScreen(viewModel = vm)
|
||||||
ShoppingScreen(viewModel = vm, searchViewModel = searchVm)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,29 +2,44 @@ package dev.ulfrx.recipe.ui.components.dock
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInParent
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composeunstyled.UnstyledButton
|
import com.composeunstyled.UnstyledButton
|
||||||
import com.composeunstyled.UnstyledIcon
|
import com.composeunstyled.UnstyledIcon
|
||||||
@@ -34,9 +49,11 @@ import com.composeunstyled.UnstyledTabList
|
|||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.search_close_a11y
|
import recipe.composeapp.generated.resources.search_close_a11y
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
|
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
|
||||||
@@ -105,33 +122,136 @@ fun DockBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so
|
||||||
|
* we can drive a `Modifier.offset { IntOffset(...) }` without re-converting
|
||||||
|
* each frame.
|
||||||
|
*/
|
||||||
|
private data class TabBounds(
|
||||||
|
val offsetXPx: Float,
|
||||||
|
val widthPx: Float,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedDockTabs(
|
private fun ExpandedDockTabs(
|
||||||
destinations: List<BottomBarDestination>,
|
destinations: List<BottomBarDestination>,
|
||||||
active: BottomBarDestination,
|
active: BottomBarDestination,
|
||||||
onTabSelect: (BottomBarDestination) -> Unit,
|
onTabSelect: (BottomBarDestination) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
// Per-tab measured bounds, populated as each cell lays out. The floating
|
||||||
|
// pill follows the active tab's entry — when `active` flips, the pill
|
||||||
|
// animates from its current bounds to the new tab's bounds (Apple-Music-
|
||||||
|
// style sliding indicator).
|
||||||
|
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
|
||||||
|
|
||||||
|
// Pill is rendered wider than the cell so the active tab visually
|
||||||
|
// dominates without resizing any other cell. The pill bleeds into the
|
||||||
|
// 2 dp inter-cell gap and slightly into adjacent cells; inactive icons +
|
||||||
|
// labels remain on top (z-order), readable above the dark substrate.
|
||||||
|
val pillExpansion = 8.dp
|
||||||
|
val pillExpansionPx = with(density) { pillExpansion.toPx() }
|
||||||
|
|
||||||
|
val pillX = remember { Animatable(0f) }
|
||||||
|
val pillW = remember { Animatable(0f) }
|
||||||
|
// Pill animates only on `active` change — never per-frame. Two LaunchedEffects:
|
||||||
|
// - keyed on `tabPositions[active]`: handles the very first measurement
|
||||||
|
// (snap, so the pill is at the correct place on cold paint).
|
||||||
|
// - keyed on `active`: handles every subsequent tap (single 200 ms tween,
|
||||||
|
// no re-launch storm). Cells are uniform-weight so the target captured
|
||||||
|
// at click time stays valid for the full animation — nothing moves
|
||||||
|
// under the pill mid-flight.
|
||||||
|
var initialized by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(tabPositions[active]) {
|
||||||
|
if (initialized) return@LaunchedEffect
|
||||||
|
val t = tabPositions[active] ?: return@LaunchedEffect
|
||||||
|
pillX.snapTo(t.offsetXPx - pillExpansionPx)
|
||||||
|
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
if (!initialized) return@LaunchedEffect
|
||||||
|
val t = tabPositions[active] ?: return@LaunchedEffect
|
||||||
|
launch {
|
||||||
|
pillX.animateTo(
|
||||||
|
targetValue = t.offsetXPx - pillExpansionPx,
|
||||||
|
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
pillW.animateTo(
|
||||||
|
targetValue = t.widthPx + 2f * pillExpansionPx,
|
||||||
|
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UnstyledTabGroup(
|
UnstyledTabGroup(
|
||||||
selectedTab = active.name,
|
selectedTab = active.name,
|
||||||
tabs = destinations.map { it.name },
|
tabs = destinations.map { it.name },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
UnstyledTabList(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = RecipeTheme.spacing.xs),
|
// sm (8 dp) inner padding gives the active pill room to
|
||||||
|
// expand up to 8 dp past its cell while still leaving the
|
||||||
|
// matching 4 dp gap to the dock's outer rounded edge on
|
||||||
|
// first / last tabs.
|
||||||
|
.padding(horizontal = RecipeTheme.spacing.sm),
|
||||||
|
) {
|
||||||
|
// Floating pill (bottom z-layer). Inset 4dp vertical / 3dp
|
||||||
|
// horizontal from the measured cell bounds — same geometry as the
|
||||||
|
// previous per-cell pill, just rendered once and animated.
|
||||||
|
if (initialized) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.offset { IntOffset(pillX.value.roundToInt(), 0) }
|
||||||
|
.width(with(density) { pillW.value.toDp() })
|
||||||
|
.fillMaxHeight()
|
||||||
|
// 4dp on all sides — matches the dock's inner
|
||||||
|
// sm padding so an edge-tab pill has equal gap
|
||||||
|
// to the outer rounded edge top/bottom AND side.
|
||||||
|
.padding(4.dp)
|
||||||
|
.background(
|
||||||
|
Color.Black.copy(alpha = 0.3f),
|
||||||
|
RoundedCornerShape(50),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab row on top — icons + labels are drawn over the pill so the
|
||||||
|
// active tab's foreground (accent) reads against the dark inset.
|
||||||
|
UnstyledTabList(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
destinations.forEach { dest ->
|
destinations.forEach { dest ->
|
||||||
val isActive = dest == active
|
|
||||||
DockTabCell(
|
DockTabCell(
|
||||||
destination = dest,
|
destination = dest,
|
||||||
isActive = isActive,
|
isActive = dest == active,
|
||||||
onClick = { onTabSelect(dest) },
|
onClick = { onTabSelect(dest) },
|
||||||
modifier = Modifier.weight(1f),
|
// Uniform weight — cells stay fixed during a tab
|
||||||
|
// switch. The active-feels-bigger emphasis is carried
|
||||||
|
// entirely by the dark pill behind the icon + label.
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
tabPositions[dest] =
|
||||||
|
TabBounds(
|
||||||
|
offsetXPx = coords.positionInParent().x,
|
||||||
|
widthPx = coords.size.width.toFloat(),
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,17 +265,19 @@ private fun DockTabCell(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
|
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
|
||||||
val pillColor = if (isActive) RecipeTheme.colors.accent.copy(alpha = 0.16f) else Color.Transparent
|
|
||||||
val labelText = stringResource(destination.labelRes)
|
val labelText = stringResource(destination.labelRes)
|
||||||
val a11ySuffix = if (isActive) ", aktywna" else ""
|
val a11ySuffix = if (isActive) ", aktywna" else ""
|
||||||
|
// Cell is just the touch target + foreground (icon + label). The pill
|
||||||
|
// background lives in [ExpandedDockTabs] as a single sliding indicator,
|
||||||
|
// so individual cells stay transparent.
|
||||||
UnstyledTab(
|
UnstyledTab(
|
||||||
key = destination.name,
|
key = destination.name,
|
||||||
selected = isActive,
|
selected = isActive,
|
||||||
onSelected = onClick,
|
onSelected = onClick,
|
||||||
activateOnFocus = false,
|
activateOnFocus = false,
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(50),
|
||||||
backgroundColor = pillColor,
|
backgroundColor = Color.Transparent,
|
||||||
contentPadding = PaddingValues(vertical = 6.dp),
|
contentPadding = PaddingValues(0.dp),
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ fun FloatingSearchButton(
|
|||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
GlassSurface(
|
GlassSurface(
|
||||||
modifier = modifier.size(56.dp),
|
modifier = modifier.size(63.dp),
|
||||||
cornerRadius = 28.dp,
|
cornerRadius = 31.5.dp,
|
||||||
) {
|
) {
|
||||||
UnstyledButton(
|
UnstyledButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -8,144 +8,73 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import com.composeunstyled.UnstyledButton
|
import com.composeunstyled.UnstyledButton
|
||||||
import com.composeunstyled.UnstyledIcon
|
import com.composeunstyled.UnstyledIcon
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
import dev.ulfrx.recipe.ui.screens.shell.LocalShellChrome
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.StringResource
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y
|
import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wires a feature's [SearchControls] VM into the shell's bottom-bar slots
|
* Bottom chrome rendered while shell-wide search is open (states B and C from
|
||||||
* (`LocalShellChrome.trailingSlot` and `LocalShellChrome.bottomOverlay`).
|
* [SearchState]).
|
||||||
*
|
*
|
||||||
* Call this once from any feature screen that wants the shared search affordance
|
* Layout decided from [SearchState.isFocused]:
|
||||||
* (Recipes, Pantry, …). The shell does not need to know which features have search;
|
* - **B (`isFocused=false`)** — `[ collapsed dock icon ] [ search pill ]`.
|
||||||
* it just renders whatever slots the active screen has supplied.
|
* Tapping the collapsed dock icon closes search and returns to [activeTab].
|
||||||
|
* - **C (`isFocused=true`)** — `[ search pill (full width) ] [ X button ]`.
|
||||||
|
* The collapsed dock icon disappears (Apple Music pattern: the left affordance
|
||||||
|
* yields to the search context). Tapping X clears the query and unfocuses
|
||||||
|
* back to State B.
|
||||||
*
|
*
|
||||||
* ## What it does
|
* Geometry mirrors the existing chrome: 45dp height, capsule shapes,
|
||||||
|
* [RecipeTheme.spacing.sm] gap between cells.
|
||||||
*
|
*
|
||||||
* - When `controls.state.isOpen == false`: trailing slot becomes a [FloatingSearchButton]
|
* Focus wiring is bi-directional: when [SearchPill]'s `BasicTextField` reports
|
||||||
* that calls `controls.open()`. Bottom overlay stays null (default DockBar visible).
|
* focus changes, [onFocusGained] / [onFocusLost] propagate them into the shell
|
||||||
*
|
* VM. When the VM commands `isFocused=false` (e.g. via X), a [LaunchedEffect]
|
||||||
* - When `controls.state.isOpen == true`: bottom overlay takes over the row,
|
* here drives `focusManager.clearFocus()` to flush the platform focus.
|
||||||
* showing a collapsed [DockBar] icon + a full-width [SearchPill] + an optional
|
|
||||||
* keyboard-dismiss button.
|
|
||||||
*
|
|
||||||
* ## Ownership protocol (important)
|
|
||||||
*
|
|
||||||
* The slot lambdas are built once per [SearchControls] instance via [remember] so
|
|
||||||
* their referential identity is stable across recompositions. On disposal we use
|
|
||||||
* a `===` identity check before nulling — that way, if the next active screen
|
|
||||||
* has already taken ownership of a slot in between (race during NavHost
|
|
||||||
* destination swap), our late-running disposer can't clobber it.
|
|
||||||
*
|
|
||||||
* @param controls per-feature search VM (Recipes / Pantry / …).
|
|
||||||
* @param placeholder localized placeholder for the [SearchPill] text field.
|
|
||||||
* @param activeTab passed to [DockBar] in collapsed mode so the right tab icon
|
|
||||||
* shows in the collapsed circle. Pass [BottomBarDestination] of the screen
|
|
||||||
* invoking this composable (e.g. `BottomBarDestination.Recipes`).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProvideSearchChrome(
|
fun SearchPillRow(
|
||||||
controls: SearchControls,
|
|
||||||
placeholder: StringResource,
|
|
||||||
activeTab: BottomBarDestination,
|
|
||||||
) {
|
|
||||||
val chrome = LocalShellChrome.current
|
|
||||||
val state by controls.state.collectAsStateWithLifecycle()
|
|
||||||
val placeholderText = stringResource(placeholder)
|
|
||||||
|
|
||||||
// Stable slot lambdas — survive recomposition as long as `controls` and
|
|
||||||
// `placeholderText` don't change. Stable identity is what makes the `===`
|
|
||||||
// ownership guards in onDispose correct.
|
|
||||||
val trailing: @Composable () -> Unit =
|
|
||||||
remember(controls) {
|
|
||||||
{ FloatingSearchButton(onClick = controls::open) }
|
|
||||||
}
|
|
||||||
val overlay: @Composable () -> Unit =
|
|
||||||
remember(controls, placeholderText, activeTab) {
|
|
||||||
{
|
|
||||||
// Subscribe to state INSIDE the slot so query updates only
|
|
||||||
// recompose this overlay, not the rest of AppShell.
|
|
||||||
val s by controls.state.collectAsStateWithLifecycle()
|
|
||||||
SearchPillRow(
|
|
||||||
query = s.query,
|
|
||||||
placeholder = placeholderText,
|
|
||||||
activeTab = activeTab,
|
|
||||||
onQueryChange = controls::onQueryChange,
|
|
||||||
onClose = controls::close,
|
|
||||||
onClear = controls::clear,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive chrome slots from isOpen. DisposableEffect re-runs whenever
|
|
||||||
// isOpen flips, swapping which slot is populated.
|
|
||||||
DisposableEffect(state.isOpen, trailing, overlay, chrome) {
|
|
||||||
if (state.isOpen) {
|
|
||||||
chrome.trailingSlot = null
|
|
||||||
chrome.bottomOverlay = overlay
|
|
||||||
} else {
|
|
||||||
chrome.bottomOverlay = null
|
|
||||||
chrome.trailingSlot = trailing
|
|
||||||
}
|
|
||||||
onDispose {
|
|
||||||
// Only clear if WE'RE still the slot owner. A `===` check prevents
|
|
||||||
// a late dispose from clobbering slots already claimed by the next
|
|
||||||
// active screen.
|
|
||||||
if (chrome.trailingSlot === trailing) chrome.trailingSlot = null
|
|
||||||
if (chrome.bottomOverlay === overlay) chrome.bottomOverlay = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replacement bottom row rendered when search is open: collapsed [DockBar] icon
|
|
||||||
* (tap = close search), full-width [SearchPill], and an optional X button to
|
|
||||||
* clear the query + dismiss the keyboard while the field is focused.
|
|
||||||
*
|
|
||||||
* Geometry mirrors the previous [dev.ulfrx.recipe.ui.screens.shell.AppShell]
|
|
||||||
* branch: 45dp height for all three children, capsule shapes, [RecipeTheme.spacing.sm]
|
|
||||||
* gap between cells.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SearchPillRow(
|
|
||||||
query: String,
|
query: String,
|
||||||
|
isFocused: Boolean,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
activeTab: BottomBarDestination,
|
activeTab: BottomBarDestination,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onClear: () -> Unit,
|
onFocusGained: () -> Unit,
|
||||||
|
onFocusLost: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val pillHeight = 45.dp
|
// Search pill / collapsed dock icon / X button — all share this height.
|
||||||
|
// AppShell vertically centres the SearchPillRow within the dock's 63dp
|
||||||
|
// band so the pill sits in the middle of the tab-bar position rather than
|
||||||
|
// nudged toward the top.
|
||||||
|
val pillHeight = 48.dp
|
||||||
|
|
||||||
|
// VM-commanded unfocus → flush platform focus from BasicTextField.
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (!isFocused) focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (!isFocused) {
|
||||||
DockBar(
|
DockBar(
|
||||||
destinations = BottomBarDestination.entries,
|
destinations = BottomBarDestination.entries,
|
||||||
active = activeTab,
|
active = activeTab,
|
||||||
@@ -154,21 +83,20 @@ private fun SearchPillRow(
|
|||||||
onCollapsedTap = onClose,
|
onCollapsedTap = onClose,
|
||||||
height = pillHeight,
|
height = pillHeight,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
SearchPill(
|
SearchPill(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = onQueryChange,
|
onQueryChange = onQueryChange,
|
||||||
onFocusChanged = { focused = it },
|
onFocusChanged = { focused ->
|
||||||
|
if (focused) onFocusGained() else onFocusLost()
|
||||||
|
},
|
||||||
placeholder = placeholder,
|
placeholder = placeholder,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
height = pillHeight,
|
height = pillHeight,
|
||||||
)
|
)
|
||||||
if (focused) {
|
if (isFocused) {
|
||||||
DismissSearchKeyboardButton(
|
DismissSearchKeyboardButton(
|
||||||
onClick = {
|
onClick = onFocusLost,
|
||||||
onClear()
|
|
||||||
focusManager.clearFocus()
|
|
||||||
focused = false
|
|
||||||
},
|
|
||||||
size = pillHeight,
|
size = pillHeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -176,12 +104,8 @@ private fun SearchPillRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 45dp circular Liquid-glass button that clears the active query and dismisses
|
* 45dp circular Liquid-glass X button. Visible only in State C — tapping it
|
||||||
* the keyboard. Visible only while the [SearchPill] field is focused.
|
* unfocuses the search field and clears the query (returns to State B).
|
||||||
*
|
|
||||||
* Lifted verbatim from the previous private helper in
|
|
||||||
* [dev.ulfrx.recipe.ui.screens.shell.AppShell] — moved here because keyboard
|
|
||||||
* dismissal is conceptually part of the search affordance, not the shell.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DismissSearchKeyboardButton(
|
private fun DismissSearchKeyboardButton(
|
||||||
|
|||||||
@@ -1,57 +1,25 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.search
|
package dev.ulfrx.recipe.ui.components.search
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-tab search state shape. Both [dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel]
|
* Shell-wide search state shape, exposed by
|
||||||
* and [dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel] expose this via
|
* [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel] as a hot
|
||||||
* their `state: StateFlow<SearchState>`.
|
* `StateFlow<SearchState>`.
|
||||||
*
|
*
|
||||||
* - [isOpen] — whether the search affordance is open on this tab.
|
* Three logical states (Apple Music pattern):
|
||||||
* - [query] — the current query echo (D-07: just an echo this phase; results
|
* - **A** — Closed: `isOpen=false`. Default tab is rendered; floating search
|
||||||
* plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively).
|
* button sits in the dock's trailing slot.
|
||||||
|
* - **B** — Open, unfocused: `isOpen=true, isFocused=false`. The SearchScreen
|
||||||
|
* is on stage with curated/quick-nav content; chrome shows collapsed dock
|
||||||
|
* icon + search pill (placeholder, no input).
|
||||||
|
* - **C** — Open, focused: `isOpen=true, isFocused=true`. Search input is
|
||||||
|
* active; chrome hides the collapsed dock icon and shows an X dismiss
|
||||||
|
* button on the right.
|
||||||
|
*
|
||||||
|
* `query` is the live input echo (results plumbing arrives once per-feature
|
||||||
|
* SearchSources exist in Phase 5/6/8/9).
|
||||||
*/
|
*/
|
||||||
data class SearchState(
|
data class SearchState(
|
||||||
val isOpen: Boolean = false,
|
val isOpen: Boolean = false,
|
||||||
|
val isFocused: Boolean = false,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 5 (Recipes) and Phase 8 (Pantry) implement and inject a real
|
|
||||||
* [SearchSource]; Phase 2.1 leaves it null. The Search VMs accept a nullable
|
|
||||||
* source today so Phase 5 / 8 only inject a dependency, not refactor the VM.
|
|
||||||
*
|
|
||||||
* Defined in `ui.components.search` (the canonical home for search shapes) —
|
|
||||||
* Phase 5 introduces the Recipes-specific implementation; Phase 8 either reuses
|
|
||||||
* or shadows with its own version. Either way, Phase 2.1 does NOT call into
|
|
||||||
* [SearchSource].
|
|
||||||
*/
|
|
||||||
interface SearchSource {
|
|
||||||
// Phase 5 / 8 add: fun observe(query: String): Flow<List<*>>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal contract a feature ViewModel must satisfy to participate in the
|
|
||||||
* shared bottom-bar search chrome via [ProvideSearchChrome].
|
|
||||||
*
|
|
||||||
* Both Recipes and Pantry search VMs already had this exact shape — making it
|
|
||||||
* an explicit interface lets [ProvideSearchChrome] take a stable VM reference
|
|
||||||
* and keep its slot lambdas referentially stable across recompositions
|
|
||||||
* (important for the `===` identity guard in the chrome ownership protocol).
|
|
||||||
*/
|
|
||||||
interface SearchControls {
|
|
||||||
/** Hot state stream — UI subscribes via `collectAsStateWithLifecycle()`. */
|
|
||||||
val state: StateFlow<SearchState>
|
|
||||||
|
|
||||||
/** Open the search affordance. */
|
|
||||||
fun open()
|
|
||||||
|
|
||||||
/** Close the search affordance. D-08: closing also clears the query. */
|
|
||||||
fun close()
|
|
||||||
|
|
||||||
/** Update the query echo. */
|
|
||||||
fun onQueryChange(q: String)
|
|
||||||
|
|
||||||
/** D-07: clear the query but keep the affordance open. */
|
|
||||||
fun clear()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,36 +16,24 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_pantry_subtitle
|
import recipe.composeapp.generated.resources.empty_pantry_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_pantry_title
|
import recipe.composeapp.generated.resources.empty_pantry_title
|
||||||
import recipe.composeapp.generated.resources.search_placeholder_pantry
|
|
||||||
import recipe.composeapp.generated.resources.shell_tab_pantry
|
import recipe.composeapp.generated.resources.shell_tab_pantry
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
|
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
|
||||||
* empty body with the inventory list.
|
* empty body with the inventory list.
|
||||||
*
|
*
|
||||||
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not
|
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||||
* know that Pantry has a search button.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PantryScreen(
|
fun PantryScreen(viewModel: PantryViewModel) {
|
||||||
viewModel: PantryViewModel,
|
|
||||||
searchViewModel: PantrySearchViewModel,
|
|
||||||
) {
|
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProvideSearchChrome(
|
|
||||||
controls = searchViewModel,
|
|
||||||
placeholder = Res.string.search_placeholder_pantry,
|
|
||||||
activeTab = BottomBarDestination.Pantry,
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_planner_subtitle
|
import recipe.composeapp.generated.resources.empty_planner_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_planner_title
|
import recipe.composeapp.generated.resources.empty_planner_title
|
||||||
import recipe.composeapp.generated.resources.search_placeholder_planner
|
|
||||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
|
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
|
||||||
* empty body with the calendar grid.
|
* empty body with the calendar grid.
|
||||||
*
|
*
|
||||||
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — search affordance
|
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||||
* is shell-wide for visual consistency across tabs.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlannerScreen(
|
fun PlannerScreen(viewModel: PlannerViewModel) {
|
||||||
viewModel: PlannerViewModel,
|
|
||||||
searchViewModel: PlannerSearchViewModel,
|
|
||||||
) {
|
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProvideSearchChrome(
|
|
||||||
controls = searchViewModel,
|
|
||||||
placeholder = Res.string.search_placeholder_planner,
|
|
||||||
activeTab = BottomBarDestination.Planner,
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_recipes_subtitle
|
import recipe.composeapp.generated.resources.empty_recipes_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_recipes_title
|
import recipe.composeapp.generated.resources.empty_recipes_title
|
||||||
import recipe.composeapp.generated.resources.search_placeholder_recipes
|
|
||||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
import recipe.composeapp.generated.resources.shell_tab_recipes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
|
* Phase 2.1 — empty-state screen for the Recipes tab. Phase 5 replaces the
|
||||||
* empty body with the recipe catalog grid.
|
* empty body with the recipe catalog grid.
|
||||||
*
|
*
|
||||||
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — the shell does not
|
* Search is now shell-wide (see `AppShell` + `ShellSearchViewModel`) — this
|
||||||
* know that Recipes has a search button. When this screen leaves composition
|
* screen no longer owns any bottom-chrome state.
|
||||||
* (tab switch), the chrome slots clear themselves automatically.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RecipesScreen(
|
fun RecipesScreen(viewModel: RecipesViewModel) {
|
||||||
viewModel: RecipesViewModel,
|
|
||||||
searchViewModel: RecipesSearchViewModel,
|
|
||||||
) {
|
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Push search chrome (FloatingSearchButton / SearchPill overlay) into the
|
|
||||||
// shell's bottom-bar slots. Lifecycle is tied to this composition.
|
|
||||||
ProvideSearchChrome(
|
|
||||||
controls = searchViewModel,
|
|
||||||
placeholder = Res.string.search_placeholder_recipes,
|
|
||||||
activeTab = BottomBarDestination.Recipes,
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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.AnimatedContent
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -13,145 +14,185 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.backhandler.BackHandler
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||||
import dev.ulfrx.recipe.navigation.TabNavigator
|
import dev.ulfrx.recipe.navigation.TabNavigator
|
||||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||||
|
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||||
|
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
||||||
|
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||||
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
import recipe.composeapp.generated.resources.search_placeholder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticated root composable. Hosts navigation and the bottom-bar chrome
|
* Authenticated root composable. Owns:
|
||||||
* skeleton, but is **agnostic about feature concerns** like search.
|
* - the per-tab navigation back stacks via [TabNavigator]
|
||||||
|
* - the shell-wide search affordance via [ShellSearchViewModel]
|
||||||
*
|
*
|
||||||
* ## Layout
|
* ## Body modes (driven by `searchVm.state.isOpen`)
|
||||||
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
|
|
||||||
* - Body: [RootNavDisplay] consumes the full screen, wrapped in [GlassBackdropSource]
|
|
||||||
* so Liquid chrome samples the screen body through `LocalGlassBackdropState`.
|
|
||||||
* - Bottom chrome (overlay): the active screen contributes its own contextual
|
|
||||||
* chrome via [ShellChromeState] / [LocalShellChrome] (see [ShellChrome.kt]).
|
|
||||||
* AppShell renders one of two modes:
|
|
||||||
* * `bottomOverlay` non-null → render the screen-supplied overlay full-width.
|
|
||||||
* * `bottomOverlay` null → render the default [DockBar] + a 56dp trailing
|
|
||||||
* slot whose contents come from `chrome.trailingSlot` (or empty).
|
|
||||||
* - The two modes are wrapped in [AnimatedContent] for a smooth cross-fade when a
|
|
||||||
* feature toggles its overlay (e.g. opening / closing search).
|
|
||||||
* - Pitfall F: navigationBars + ime padding only; no `safeContentPadding()`.
|
|
||||||
*
|
*
|
||||||
* ## Active-tab tracking
|
* - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom
|
||||||
* Read directly from [TabNavigator.activeTab], which is `MutableState<…>` so
|
* chrome is `[DockBar (full)] [FloatingSearchButton]`.
|
||||||
* recomposition is automatic on tab switch. No mirror state needed — the
|
* - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom
|
||||||
* navigator is the single source of truth.
|
* chrome is [SearchPillRow], whose layout shifts further on `isFocused`
|
||||||
|
* (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt).
|
||||||
|
*
|
||||||
|
* ## Back-press handling
|
||||||
|
*
|
||||||
|
* While search is open, a [BackHandler] consumes the back press as a no-op:
|
||||||
|
* the user must exit search explicitly via the collapsed dock icon (B→A) or X
|
||||||
|
* (C→B). Confirmed product decision — no implicit dismissal while in search.
|
||||||
*
|
*
|
||||||
* ## Why TabNavigator and not the AndroidX NavController
|
* ## Why TabNavigator and not the AndroidX NavController
|
||||||
* Phase 2.1 originally wired the dock through a single Nav-2 `NavHost` with
|
* (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See
|
||||||
* four nested `navigation<…>` sub-graphs using `popUpTo + saveState +
|
* [RootNavDisplay] for the full rationale.)
|
||||||
* restoreState` for multi-back-stack. Nav 3 replaces that with an app-owned
|
|
||||||
* back stack (a `SnapshotStateList<NavKey>`), so [TabNavigator] holds **one
|
|
||||||
* stack per tab** and [RootNavDisplay] renders only the active one inside a
|
|
||||||
* `NavDisplay`. The implementation closely mirrors the
|
|
||||||
* [To-Do-CMP reference](https://github.com/stevdza-san/To-Do-CMP) pattern —
|
|
||||||
* `Navigator` + `NavDisplay(entryProvider = …)` — extended for the dock's
|
|
||||||
* parallel-stack requirement.
|
|
||||||
*
|
|
||||||
* ## What used to live here, and why it moved
|
|
||||||
* The previous version of this file knew about `RecipesSearchViewModel` and
|
|
||||||
* `PantrySearchViewModel` directly, with `when (activeTab)` branches forwarding
|
|
||||||
* open/close/clear into the right VM. That coupling was unnecessary: the active
|
|
||||||
* screen already has the VM and is the right place to express its own chrome.
|
|
||||||
* The slot pattern means adding a contextual button to a future tab (a Planner
|
|
||||||
* filter, say) is a one-file change in that screen — AppShell never grows.
|
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the
|
||||||
|
// latter is overkill for a static "consume back" guard. Revisit when stable.
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AppShell(modifier: Modifier = Modifier) {
|
fun AppShell(modifier: Modifier = Modifier) {
|
||||||
val navigator = remember { TabNavigator() }
|
val navigator = remember { TabNavigator() }
|
||||||
|
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||||
|
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Single chrome-state holder for the lifetime of the shell. Provided to
|
BackHandler(enabled = searchState.isOpen) {
|
||||||
// descendants via LocalShellChrome.
|
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
||||||
val chrome = remember { ShellChromeState() }
|
}
|
||||||
|
|
||||||
// NB: we deliberately do NOT clear chrome on activeTab changes. The active
|
|
||||||
// screen's `DisposableEffect` cleanup (in `ProvideSearchChrome` or similar)
|
|
||||||
// is responsible for releasing its slots when it leaves composition. Doing
|
|
||||||
// a redundant clear here would race with the new screen's setup.
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalShellChrome provides chrome) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
// Body — RootNavDisplay fills the available space and is the shared source
|
// Body — cross-fade between the tab stack and the search overlay.
|
||||||
// layer for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03).
|
|
||||||
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = searchState.isOpen,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||||
|
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
|
||||||
|
},
|
||||||
|
label = "AppShell body",
|
||||||
|
) { searchOpen ->
|
||||||
|
if (searchOpen) {
|
||||||
|
SearchScreen(viewModel = searchVm)
|
||||||
|
} else {
|
||||||
RootNavDisplay(
|
RootNavDisplay(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom chrome — one Row, two layout modes (default / overlay) chosen
|
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
|
||||||
// by AnimatedContent for a clean cross-fade.
|
// inset (home indicator) for the bottom edge; halve it so chrome sits
|
||||||
|
// close to the bottom and the home indicator visually overlaps the
|
||||||
|
// chrome substrate. When IME is up, use the full IME inset (it's much
|
||||||
|
// larger than navInset/2, so `max` keeps the chrome above the keyboard).
|
||||||
|
val bottomInset =
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
val imePx = WindowInsets.ime.getBottom(this)
|
||||||
|
val navPx = WindowInsets.navigationBars.getBottom(this)
|
||||||
|
maxOf(imePx, navPx / 2).toDp()
|
||||||
|
}
|
||||||
|
// Horizontal chrome padding animates with the search state:
|
||||||
|
// - Closed (dock visible) → xl (24 dp)
|
||||||
|
// - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly
|
||||||
|
// inset from the dock's footprint
|
||||||
|
// - Open, focused (search C) → 8 dp, so the input reads as a width
|
||||||
|
// extension of the keyboard above it
|
||||||
|
val horizontalPadding by animateDpAsState(
|
||||||
|
targetValue =
|
||||||
|
when {
|
||||||
|
!searchState.isOpen -> RecipeTheme.spacing.xl
|
||||||
|
!searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp
|
||||||
|
else -> 8.dp
|
||||||
|
},
|
||||||
|
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||||
|
label = "chrome horizontal padding",
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
|
||||||
.imePadding()
|
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = RecipeTheme.spacing.lg,
|
start = horizontalPadding,
|
||||||
vertical = RecipeTheme.spacing.sm,
|
end = horizontalPadding,
|
||||||
|
top = RecipeTheme.spacing.sm,
|
||||||
|
bottom = bottomInset + RecipeTheme.spacing.xs,
|
||||||
),
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val overlay = chrome.bottomOverlay
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = overlay != null,
|
targetState = searchState.isOpen,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// Lock chrome region to the dock's height in both modes so
|
||||||
|
// (a) the body above doesn't shift when search opens / closes,
|
||||||
|
// and (b) the (shorter) search pill is centred vertically
|
||||||
|
// inside the same band the dock occupies.
|
||||||
|
modifier = Modifier.fillMaxWidth().height(63.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||||
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
|
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
|
||||||
},
|
},
|
||||||
label = "AppShell bottom chrome",
|
label = "AppShell bottom chrome",
|
||||||
) { useOverlay ->
|
) { searchOpen ->
|
||||||
if (useOverlay) {
|
if (searchOpen) {
|
||||||
// Re-read current overlay inside this branch — chrome state
|
SearchPillRow(
|
||||||
// can change after the targetState was captured.
|
query = searchState.query,
|
||||||
chrome.bottomOverlay?.invoke()
|
isFocused = searchState.isFocused,
|
||||||
|
placeholder = stringResource(Res.string.search_placeholder),
|
||||||
|
activeTab = navigator.activeTab,
|
||||||
|
onQueryChange = searchVm::onQueryChange,
|
||||||
|
onClose = searchVm::close,
|
||||||
|
onFocusGained = searchVm::focus,
|
||||||
|
onFocusLost = searchVm::unfocus,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
DefaultDockRow(
|
DefaultDockRow(
|
||||||
activeTab = navigator.activeTab,
|
activeTab = navigator.activeTab,
|
||||||
onTabSelect = navigator::selectTab,
|
onTabSelect = navigator::selectTab,
|
||||||
trailingSlot = chrome.trailingSlot,
|
onSearchTap = searchVm::open,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultDockRow(
|
private fun DefaultDockRow(
|
||||||
activeTab: BottomBarDestination,
|
activeTab: BottomBarDestination,
|
||||||
onTabSelect: (BottomBarDestination) -> Unit,
|
onTabSelect: (BottomBarDestination) -> Unit,
|
||||||
trailingSlot: (@Composable () -> Unit)?,
|
onSearchTap: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -165,10 +206,10 @@ private fun DefaultDockRow(
|
|||||||
onTabSelect = onTabSelect,
|
onTabSelect = onTabSelect,
|
||||||
onCollapsedTap = { /* unreachable in default mode */ },
|
onCollapsedTap = { /* unreachable in default mode */ },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
height = 56.dp,
|
height = 63.dp,
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.size(56.dp)) {
|
Box(modifier = Modifier.size(63.dp)) {
|
||||||
trailingSlot?.invoke()
|
FloatingSearchButton(onClick = onSearchTap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.components.search.ProvideSearchChrome
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.empty_shopping_subtitle
|
import recipe.composeapp.generated.resources.empty_shopping_subtitle
|
||||||
import recipe.composeapp.generated.resources.empty_shopping_title
|
import recipe.composeapp.generated.resources.empty_shopping_title
|
||||||
import recipe.composeapp.generated.resources.search_placeholder_shopping
|
|
||||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
|
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
|
||||||
* empty body with the shopping list + session UI.
|
* empty body with the shopping list + session UI.
|
||||||
*
|
*
|
||||||
* Owns its own bottom-bar chrome via [ProvideSearchChrome] — search affordance
|
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||||
* is shell-wide for visual consistency across tabs.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ShoppingScreen(
|
fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
||||||
viewModel: ShoppingViewModel,
|
|
||||||
searchViewModel: ShoppingSearchViewModel,
|
|
||||||
) {
|
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProvideSearchChrome(
|
|
||||||
controls = searchViewModel,
|
|
||||||
placeholder = Res.string.search_placeholder_shopping,
|
|
||||||
activeTab = BottomBarDestination.Shopping,
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 = "") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,6 +40,7 @@ androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle
|
|||||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||||
|
compose-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "composeMultiplatform" }
|
||||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
||||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
|
|||||||
Reference in New Issue
Block a user