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