diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 0153a12..12b56c0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -30,10 +30,12 @@
Otwórz wyszukiwanie
- Zamknij wyszukiwanie
Wyczyść i ukryj klawiaturę
Wyczyść
+
+ Rozwiń pasek nawigacji
+
Twój plan tygodnia czeka
Wkrótce zobaczysz tu zaplanowane posiłki.
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/DockDestination.kt
similarity index 62%
rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt
rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/DockDestination.kt
index c770e2a..70a9f29 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/DockDestination.kt
@@ -13,21 +13,7 @@ import recipe.composeapp.generated.resources.shell_tab_planner
import recipe.composeapp.generated.resources.shell_tab_recipes
import recipe.composeapp.generated.resources.shell_tab_shopping
-/**
- * The 4 bottom-bar destinations in left→right order per CONTEXT D-03:
- * Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
- * default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal
- * listing order, which research confirmed is non-binding.
- *
- * 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.
- *
- * 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(
+enum class DockDestination(
val startDestination: Screen,
val labelRes: StringResource,
val icon: ImageVector,
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
;
companion object {
- /** Default landing tab — CONTEXT D-03. */
- val Default: BottomBarDestination = Planner
+ val Default: DockDestination = Planner
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt
index 8024ae2..ed49d8a 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt
@@ -9,21 +9,21 @@ import androidx.compose.runtime.mutableStateListOf
@Stable
class TabNavigator(
- initialTab: BottomBarDestination = BottomBarDestination.Default,
+ initialTab: DockDestination = DockDestination.Default,
) {
- private val backStacks: Map> =
- BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
+ private val backStacks: Map> =
+ DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
- var activeTab: BottomBarDestination by mutableStateOf(initialTab)
+ var activeTab: DockDestination by mutableStateOf(initialTab)
private set
val activeBackStack: SnapshotStateList
get() = backStacks.getValue(activeTab)
- fun backStackFor(tab: BottomBarDestination): SnapshotStateList =
+ fun backStackFor(tab: DockDestination): SnapshotStateList =
backStacks.getValue(tab)
- fun selectTab(tab: BottomBarDestination) {
+ fun selectTab(tab: DockDestination) {
if (tab == activeTab) {
popToRoot(tab)
} else {
@@ -35,14 +35,14 @@ class TabNavigator(
activeBackStack.add(screen)
}
- fun goBack(tab: BottomBarDestination = activeTab) {
+ fun goBack(tab: DockDestination = activeTab) {
val stack = backStacks.getValue(tab)
if (stack.size > 1) {
stack.removeAt(stack.lastIndex)
}
}
- private fun popToRoot(tab: BottomBarDestination) {
+ private fun popToRoot(tab: DockDestination) {
val stack = backStacks.getValue(tab)
while (stack.size > 1) {
stack.removeAt(stack.lastIndex)
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 63ef519..a9b1ee0 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
@@ -1,21 +1,16 @@
package dev.ulfrx.recipe.ui.components.dock
-import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.IndicationNodeFactory
-import androidx.compose.foundation.LocalIndication
-import androidx.compose.foundation.border
-import androidx.compose.foundation.interaction.InteractionSource
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -27,7 +22,6 @@ 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
@@ -36,426 +30,406 @@ 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.alpha
import androidx.compose.ui.draw.scale
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.unit.times
import com.composeunstyled.UnstyledIcon
-import com.composeunstyled.UnstyledTab
-import com.composeunstyled.UnstyledTabGroup
-import com.composeunstyled.UnstyledTabList
-import dev.ulfrx.recipe.navigation.BottomBarDestination
+import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
-import kotlinx.coroutines.coroutineScope
-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 recipe.composeapp.generated.resources.dock_expand_a11y
import kotlin.math.roundToInt
-/**
- * Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
- *
- * Two structurally distinct shapes:
- * - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
- * Icon + label always shown (D-02); the sliding pill follows the active
- * tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
- * with `height / 2` corner radius.
- * - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton]
- * showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes
- * search per D-05).
- *
- * The two shapes are NOT animated between in-place — AppShell already
- * cross-fades the expanded and collapsed instances via its own [AnimatedContent]
- * when search opens / closes.
- *
- * ## Why the substrate is a *sibling* of the pill (not a parent)
- *
- * The pressed-tab affordance ([ExpandedDockTabs]) scales the pill up to 1.20×.
- * For the pill to visibly extend *past* the dock's rounded contours, it cannot
- * live inside the dock's [GlassSurface], whose `Modifier.clip(shape)` would
- * crop it back to the rounded rect. So we wrap both in a no-clip [Box] and
- * draw the pill as a sibling on top of the substrate — that's also why the
- * substrate's `content` block is empty here.
- *
- * Substrate: only the shared [GlassSurface] / [CircleGlassButton] are used —
- * direct Liquid API calls are forbidden here per CLAUDE.md non-negotiable #10.
- *
- * Touch targets: each tab cell + collapsed toggle is ≥ 44 dp (UI-SPEC line 52, 224).
- */
+private val PressOverlayBleed = 4.dp
+private val PressOverlayVerticalInset = 0.dp
+private val ActiveIndicatorBleed = 4.dp
+private val ActiveIndicatorVerticalInset = 5.dp
+private val ActiveIndicatorEdgeInset = 5.dp
+private val DockTabIconSize = 18.dp
+private val DockTabIconLabelGap = 2.dp
+private const val PressOverlayScale = 1.22f
+private const val DockTabLabelFontSizeSp = 11
+private const val DockTabLabelLineHeightSp = 13
+private const val OverlaySlideDurationMs = 200
+private const val OverlayFadeDurationMs = 120
+
@Composable
fun DockBar(
- destinations: List,
- active: BottomBarDestination,
+ destinations: List,
+ active: DockDestination,
collapsed: Boolean,
- onTabSelect: (BottomBarDestination) -> Unit,
- onCollapsedTap: () -> Unit,
+ onTabSelect: (DockDestination) -> Unit,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
if (collapsed) {
CircleGlassButton(
- onClick = onCollapsedTap,
+ onClick = { onTabSelect(active) },
icon = active.icon,
- contentDescription = stringResource(Res.string.search_close_a11y),
+ contentDescription = stringResource(Res.string.dock_expand_a11y),
modifier = modifier,
size = height,
iconTint = RecipeTheme.colors.accent,
)
} else {
- // Outer Box — no clip — hosts the dock substrate AND the tabs+pill
- // layer so the pressed pill can scale (1.20×) past the dock contours.
- Box(modifier = modifier.height(height)) {
- // Substrate. Border is suppressed here so we can re-draw it on
- // TOP of the pill at the end of the stack — that way the dock's
- // outline stays visible through the (inner) pill GlassSurface,
- // especially when the pill is pressed and scales past the dock.
- GlassSurface(
- modifier = Modifier.fillMaxSize(),
- cornerRadius = height / 2,
- border = null,
- ) {
- // Empty: the actual pill + tabs live in the sibling overlay
- // below, outside this GlassSurface's content clip.
+ DockBarExpanded(
+ destinations = destinations,
+ active = active,
+ onTabSelect = onTabSelect,
+ modifier = modifier,
+ height = height,
+ )
+ }
+}
+
+@Composable
+private fun DockBarExpanded(
+ destinations: List,
+ active: DockDestination,
+ onTabSelect: (DockDestination) -> Unit,
+ modifier: Modifier,
+ height: Dp,
+) {
+ val tabBounds = remember { mutableStateMapOf() }
+ var pressedX by remember { mutableStateOf(null) }
+ var dockWidthPx by remember { mutableStateOf(0f) }
+ val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
+
+ Box(
+ modifier = modifier
+ .height(height)
+ .onSizeChanged { dockWidthPx = it.width.toFloat() }
+ .pointerInput(destinations) {
+ trackDockGesture(
+ onPressXChange = { pressedX = it },
+ onCommit = { x ->
+ floorTabIndex(x, tabBounds)?.let { idx ->
+ onTabSelect(destinations[idx])
+ }
+ },
+ )
+ },
+ ) {
+ DockSubstrate(cornerRadius = height / 2)
+ DockActiveIndicatorLayer(
+ activeIndex = activeIndex,
+ visible = pressedX == null,
+ tabBounds = tabBounds,
+ dockWidthPx = dockWidthPx,
+ )
+ DockPressOverlayLayer(
+ pressedX = pressedX,
+ activeIndex = activeIndex,
+ tabBounds = tabBounds,
+ dockWidthPx = dockWidthPx,
+ dockHeight = height,
+ )
+ DockTabRow(
+ destinations = destinations,
+ activeIndex = activeIndex,
+ tabBounds = tabBounds,
+ dockWidthPx = dockWidthPx,
+ onTabSelectFromA11y = onTabSelect,
+ onTabBoundsChange = { index, bounds -> tabBounds[index] = bounds },
+ )
+ }
+}
+
+@Composable
+private fun DockSubstrate(cornerRadius: Dp) {
+ GlassSurface(
+ modifier = Modifier.fillMaxSize(),
+ cornerRadius = cornerRadius,
+ recordAsSource = true,
+ ) {}
+}
+
+@Composable
+private fun DockActiveIndicatorLayer(
+ activeIndex: Int,
+ visible: Boolean,
+ tabBounds: Map,
+ dockWidthPx: Float,
+) {
+ val alpha by animateFloatAsState(
+ targetValue = if (visible) 1f else 0f,
+ animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
+ label = "Dock active indicator alpha",
+ )
+ val bounds = tabBounds[activeIndex] ?: return
+ if (alpha <= 0f || dockWidthPx <= 0f) return
+
+ val density = LocalDensity.current
+ val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
+
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
+ .width(with(density) { bbox.widthPx.toDp() })
+ .fillMaxHeight()
+ .padding(vertical = ActiveIndicatorVerticalInset)
+ .alpha(alpha)
+ .background(
+ color = RecipeTheme.colors.chromeActive,
+ shape = RoundedCornerShape(50),
+ ),
+ )
+}
+
+@Composable
+private fun DockPressOverlayLayer(
+ pressedX: Float?,
+ activeIndex: Int,
+ tabBounds: Map,
+ dockWidthPx: Float,
+ dockHeight: Dp,
+) {
+ val activeBounds = tabBounds[activeIndex] ?: return
+ val activeCenterX = activeBounds.offsetXPx + activeBounds.widthPx / 2f
+
+ val density = LocalDensity.current
+ val bleedPx = with(density) { PressOverlayBleed.toPx() }
+ val overlayWidthPx = activeBounds.widthPx + 2 * bleedPx
+ val centerXMin = overlayWidthPx / 2f
+ val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin)
+ val clampedPressedX = pressedX?.coerceIn(centerXMin, centerXMax)
+ val overlayCenterX = rememberPressOverlayCenterX(clampedPressedX, activeCenterX)
+
+ val alpha by animateFloatAsState(
+ targetValue = if (pressedX != null) 1f else 0f,
+ animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
+ label = "Dock press overlay alpha",
+ )
+ if (alpha <= 0f || dockWidthPx <= 0f) return
+
+ val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
+ val leftPx = overlayCenterX - overlayWidthPx / 2f
+ GlassSurface(
+ modifier = Modifier
+ .offset { IntOffset(leftPx.roundToInt(), 0) }
+ .width(with(density) { overlayWidthPx.toDp() })
+ .fillMaxHeight()
+ .padding(vertical = PressOverlayVerticalInset)
+ .scale(PressOverlayScale)
+ .alpha(alpha),
+ cornerRadius = cornerRadius,
+ glassStyle = RecipeTheme.glass.dockPress,
+ ) {}
+}
+
+@Composable
+private fun DockTabRow(
+ destinations: List,
+ activeIndex: Int,
+ tabBounds: Map,
+ dockWidthPx: Float,
+ onTabSelectFromA11y: (DockDestination) -> Unit,
+ onTabBoundsChange: (Int, TabBounds) -> Unit,
+) {
+ val density = LocalDensity.current
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ destinations.forEachIndexed { index, destination ->
+ val cellBounds = tabBounds[index]
+ val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
+ val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
+ val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
+ bbox.centerPx - cellCenterX
+ } else {
+ 0f
}
-
- ExpandedDockTabs(
- destinations = destinations,
- active = active,
- dockHeight = height,
- onTabSelect = onTabSelect,
- )
-
- // Top-z dock outline so the substrate's contour reads even where
- // the pill overlaps it. Pure hairline (no fill) — purely a draw
- // marker; doesn't intercept input.
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .border(
- BorderStroke(1.dp, RecipeTheme.colors.borderCard),
- RoundedCornerShape(height / 2),
- ),
+ DockTabItem(
+ destination = destination,
+ isActive = index == activeIndex,
+ contentOffsetPx = contentOffsetPx,
+ onSelect = { onTabSelectFromA11y(destination) },
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .onGloballyPositioned { coords ->
+ onTabBoundsChange(
+ index,
+ TabBounds(
+ offsetXPx = coords.positionInParent().x,
+ widthPx = coords.size.width.toFloat(),
+ ),
+ )
+ },
)
}
}
}
-/**
- * Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so
- * we can drive a `Modifier.offset { IntOffset(...) }` without re-converting
- * each frame.
- */
+@Composable
+private fun DockTabItem(
+ destination: DockDestination,
+ isActive: Boolean,
+ contentOffsetPx: Float,
+ onSelect: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val label = stringResource(destination.labelRes)
+ val a11yLabel = if (isActive) "$label, aktywna" else label
+ val tint = RecipeTheme.colors.content
+ Box(
+ modifier = modifier.semantics {
+ role = Role.Tab
+ selected = isActive
+ contentDescription = a11yLabel
+ onClick {
+ onSelect()
+ true
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ UnstyledIcon(
+ imageVector = destination.icon,
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(DockTabIconSize),
+ )
+ Spacer(modifier = Modifier.size(DockTabIconLabelGap))
+ BasicText(
+ text = label,
+ style = RecipeTheme.typography.label.copy(
+ color = tint,
+ fontSize = DockTabLabelFontSizeSp.sp,
+ lineHeight = DockTabLabelLineHeightSp.sp,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun rememberPressOverlayCenterX(
+ pressedCenterX: Float?,
+ activeCenterX: Float,
+): Float {
+ val animatable = remember { Animatable(activeCenterX) }
+ var wasPressed by remember { mutableStateOf(false) }
+
+ LaunchedEffect(pressedCenterX, activeCenterX) {
+ when {
+ pressedCenterX == null -> {
+ wasPressed = false
+ animatable.animateTo(
+ targetValue = activeCenterX,
+ animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
+ )
+ }
+ !wasPressed -> {
+ wasPressed = true
+ animatable.animateTo(
+ targetValue = pressedCenterX,
+ animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
+ )
+ }
+ else -> {
+ animatable.snapTo(pressedCenterX)
+ }
+ }
+ }
+
+ return animatable.value
+}
+
+private suspend fun PointerInputScope.trackDockGesture(
+ onPressXChange: (Float?) -> Unit,
+ onCommit: (Float) -> Unit,
+) {
+ awaitEachGesture {
+ val down = awaitFirstDown(requireUnconsumed = false)
+ down.consume()
+ val pointerId = down.id
+ onPressXChange(down.position.x)
+
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull { it.id == pointerId }
+ if (change == null) {
+ onPressXChange(null)
+ break
+ }
+ if (!change.pressed) {
+ onCommit(change.position.x)
+ onPressXChange(null)
+ change.consume()
+ break
+ }
+ if (change.positionChanged()) {
+ onPressXChange(change.position.x)
+ }
+ change.consume()
+ }
+ }
+}
+
+private fun floorTabIndex(x: Float, bounds: Map): Int? {
+ if (bounds.isEmpty()) return null
+ val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
+ var result = sorted.first().key
+ for (entry in sorted) {
+ if (entry.value.offsetXPx <= x) {
+ result = entry.key
+ } else {
+ break
+ }
+ }
+ return result
+}
+
private data class TabBounds(
val offsetXPx: Float,
val widthPx: Float,
)
-@Composable
-private fun ExpandedDockTabs(
- destinations: List,
- active: BottomBarDestination,
- dockHeight: Dp,
- onTabSelect: (BottomBarDestination) -> Unit,
+private data class ActiveIndicatorBbox(
+ val leftPx: Float,
+ val rightPx: Float,
) {
- val density = LocalDensity.current
-
- val tabPositions = remember { mutableStateMapOf() }
-
- // One [MutableInteractionSource] per tab so the pill can react to whichever
- // tab the finger is *currently* down on — not just the active one.
- val interactionSources =
- remember(destinations) {
- destinations.associateWith { MutableInteractionSource() }
- }
-
- // Subscribe to each tab's press state. `forEach` is inline, so the
- // @Composable scope of this function propagates into the loop body and
- // `collectIsPressedAsState` is a legal call here. `pressedTab` is a plain
- // local recomputed per recomposition (cheap; only 4 tabs).
- var pressedTab: BottomBarDestination? = null
- destinations.forEach { dest ->
- val pressed by interactionSources.getValue(dest).collectIsPressedAsState()
- if (pressed) pressedTab = dest
- }
-
- // The pill follows whichever tab the finger is on; it settles back to
- // the active tab once the press ends (with no click) OR onSelected has
- // already updated `active` to match (with a click).
- val pillTargetTab = pressedTab ?: active
-
- // Pill is rendered wider than the cell so the indicator visually
- // dominates without resizing any other cell. The pill bleeds into the
- // 2 dp inter-cell gap and slightly into adjacent cells; 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) }
- val pillScale = remember { Animatable(1f) }
- var initialized by remember { mutableStateOf(false) }
- // Drives the pill's tint: while either is true the pill stays translucent
- // ("glass"); once both go false the pill settles to an opaque resting
- // tint. `isPressActive` covers the user holding a finger down; the two
- // `isXxxAnimating` flags cover the X/W slide and the scale-back-down so
- // the pill stays glassy until the animations have fully settled.
- var isXWAnimating by remember { mutableStateOf(false) }
- var isScaleAnimating by remember { mutableStateOf(false) }
-
- // First measurement: snap pill to the active cell so cold paint is correct.
- LaunchedEffect(tabPositions[pillTargetTab]) {
- if (initialized) return@LaunchedEffect
- val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
- pillX.snapTo(t.offsetXPx - pillExpansionPx)
- pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
- initialized = true
- }
-
- // Every subsequent change to the *target* tab — whether triggered by a tap
- // (active changes) or by a press-down on an inactive tab (pressedTab
- // changes) — animates the pill across in a single 200 ms tween. Cells are
- // uniform-weight so the bounds captured here stay valid for the full
- // animation; nothing moves under the pill mid-flight.
- LaunchedEffect(pillTargetTab) {
- if (!initialized) return@LaunchedEffect
- val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
- isXWAnimating = true
- try {
- coroutineScope {
- 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),
- )
- }
- }
- } finally {
- isXWAnimating = false
- }
- }
-
- // Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
- // FastOutSlowInEasing so all chrome interactions read uniformly.
- //
- // - Scale 1.0 → 1.35: the pill "lifts" past the dock's outer rounded
- // contours. The rest pill sits at a 4 dp vertical inset (visual height
- // = dockHeight − 8 dp). 1.35× grows it by ~10 dp on each side from its
- // centre, which leaves ~6 dp sticking out above and below the dock —
- // clearly past the substrate, not hugging the edge.
- // - Same uniform factor on width preserves the rest pill's shape (a
- // full capsule, cornerRadius = height/2 scales with the rest of the
- // rect, so the scaled pill is *the same shape, just bigger*).
- val isPressActive = pressedTab != null
- LaunchedEffect(isPressActive) {
- isScaleAnimating = true
- try {
- pillScale.animateTo(
- targetValue = if (isPressActive) 1.35f else 1f,
- animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
- )
- } finally {
- isScaleAnimating = false
- }
- }
-
- // Pill is "busy" (and therefore stays glassy) while the user is holding
- // it OR while it's still animating in any axis. Once everything settles,
- // it crossfades to an opaque resting tint so the active tab reads as a
- // clear solid pill rather than a translucent ghost.
- val isPillBusy = isPressActive || isXWAnimating || isScaleAnimating
- val pillBusyTint = Color.White.copy(alpha = 0.18f)
- val pillRestingTint = Color(0xFF44474B)
- val pillTint by animateColorAsState(
- targetValue = if (isPillBusy) pillBusyTint else pillRestingTint,
- animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
- label = "Dock pill tint",
- )
- // Border only reads while the pill is glassy — when the pill settles to
- // the opaque resting tint it becomes a solid plate and a hairline would
- // just compete with the dock's outer outline. Animate the stroke's alpha
- // so the border crossfades in/out together with the tint.
- val pillBorderTarget = RecipeTheme.colors.borderCard
- val pillBorderColor by animateColorAsState(
- targetValue = if (isPillBusy) pillBorderTarget else pillBorderTarget.copy(alpha = 0f),
- animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
- label = "Dock pill border",
- )
- // Liquid's `edge` rim is rendered even when the tint is fully opaque (the
- // lens itself is nullified, but rim lighting still draws). Zero it out in
- // the resting state — otherwise the pill keeps a visible bright outline
- // even when we wanted a clean solid plate.
- val pillEdge by animateFloatAsState(
- targetValue = if (isPillBusy) 0.05f else 0f,
- animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
- label = "Dock pill edge",
- )
-
- // Pill's resting visual height after the 4 dp inset on all sides.
- val pillCorner = (dockHeight - 8.dp) / 2
-
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- // sm (8 dp) inner padding gives the 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),
- ) {
- if (initialized) {
- // The pill itself — a [GlassSurface] so the press-state can morph
- // from "dark dim" to "glass" by tint alone, smoothly. Drawn FIRST
- // so the tab list renders on top; .scale() at the end of the chain
- // grows the pill (including its rounded clip) past the laid-out
- // bounds with no parent clip to crop it.
- GlassSurface(
- modifier =
- Modifier
- .offset { IntOffset(pillX.value.roundToInt(), 0) }
- .width(with(density) { pillW.value.toDp() })
- .fillMaxHeight()
- .padding(4.dp)
- .scale(pillScale.value),
- cornerRadius = pillCorner,
- tint = pillTint,
- border = BorderStroke(1.dp, pillBorderColor),
- edgeIntensity = pillEdge,
- ) {}
- }
-
- // Tab row on top — icons + labels are drawn over the pill so the
- // active tab's foreground (accent) reads against the dark inset, and
- // the press-glass tint never obscures the pressed cell's icon.
- //
- // [NoIndication] override: `UnstyledTab`'s `indication` parameter is
- // non-nullable (unlike `UnstyledButton`'s), so we can't pass `null` to
- // suppress the platform state-layer / ripple. The pill IS our press
- // indication; without this override the platform ripple draws inside
- // the tab cell *under* the scaled glass pill, reading as a stray dark
- // tint bleeding through.
- CompositionLocalProvider(LocalIndication provides NoIndication) {
- UnstyledTabGroup(
- selectedTab = active.name,
- tabs = destinations.map { it.name },
- modifier = Modifier.fillMaxSize(),
- ) {
- UnstyledTabList(
- modifier = Modifier.fillMaxSize(),
- horizontalArrangement = Arrangement.spacedBy(2.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- destinations.forEach { dest ->
- DockTabCell(
- destination = dest,
- isActive = dest == active,
- interactionSource = interactionSources.getValue(dest),
- onClick = { onTabSelect(dest) },
- // Uniform weight — cells stay fixed during a tab
- // switch. The "active feels bigger" emphasis is
- // carried by the pill (size + tint), not by
- // resizing the cell.
- modifier =
- Modifier
- .weight(1f)
- .onGloballyPositioned { coords ->
- tabPositions[dest] =
- TabBounds(
- offsetXPx = coords.positionInParent().x,
- widthPx = coords.size.width.toFloat(),
- )
- },
- )
- }
- }
- }
- }
- }
+ val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
+ val centerPx: Float get() = (leftPx + rightPx) / 2f
}
-/**
- * No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current]
- * around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't
- * paint a platform state-layer / ripple inside the cell — that would draw
- * *under* the scaled-up glass pill and read as a stray tint bleeding through.
- *
- * The pill (size + glass tint) IS the press affordance; nothing else needed.
- */
-private object NoIndication : IndicationNodeFactory {
- override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {}
-
- override fun hashCode(): Int = 0
-
- override fun equals(other: Any?): Boolean = other === this
-}
-
-@Composable
-private fun DockTabCell(
- destination: BottomBarDestination,
- isActive: Boolean,
- interactionSource: MutableInteractionSource,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- // Both states are fully opaque (alpha 1.0) — chrome foreground must not
- // visually compete with the glass tafla underneath. `contentMuted` reads
- // as transparent over translucent glass, so we use `content` for inactive
- // tabs and rely on `accent` (saturated) to call out the active one.
- val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.content
- val labelText = stringResource(destination.labelRes)
- val a11ySuffix = if (isActive) ", aktywna" else ""
- UnstyledTab(
- key = destination.name,
- selected = isActive,
- onSelected = onClick,
- activateOnFocus = false,
- interactionSource = interactionSource,
- shape = RoundedCornerShape(50),
- backgroundColor = Color.Transparent,
- contentPadding = PaddingValues(0.dp),
- modifier =
- modifier
- .fillMaxSize()
- .semantics {
- contentDescription = labelText + a11ySuffix
- },
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- UnstyledIcon(
- imageVector = destination.icon,
- contentDescription = null,
- tint = tint,
- modifier = Modifier.size(22.dp),
- )
- Spacer(modifier = Modifier.size(2.dp))
- BasicText(
- text = labelText,
- style = RecipeTheme.typography.label.copy(color = tint),
- )
- }
- }
- }
+private fun activeIndicatorBboxFor(
+ cell: TabBounds,
+ dockWidthPx: Float,
+ density: Density,
+): ActiveIndicatorBbox {
+ val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
+ val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
+ val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
+ val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
+ return ActiveIndicatorBbox(left, right)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt
index 9501a48..33844ee 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt
@@ -24,22 +24,6 @@ import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
-/**
- * Circular Liquid-glass icon button with iOS-style press feedback.
- *
- * Visual behaviour on press:
- * - Scale 1.0 → 1.15 (whole button briefly grows under the finger).
- * - Substrate tint shifts from [RecipeTheme.colors.surfaceGlass] to a
- * translucent white overlay, so the button reads "lit up".
- *
- * Both transitions use the same 120 ms tween with [FastOutSlowInEasing], so
- * the scale and tint move together. Compose's default [androidx.compose.foundation.Indication]
- * (ripple / state-layer) is suppressed (`indication = null`) — this scale +
- * tint pair is the project's standard press affordance for circular chrome.
- *
- * Used by the dock's floating search button, the search overlay's dismiss
- * button, and any future round glass action in the chrome family.
- */
@Composable
fun CircleGlassButton(
onClick: () -> Unit,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
index 7ec4339..6e188fc 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
@@ -3,48 +3,35 @@ package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
-import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
+import io.github.fletchmckee.liquid.LiquidState
+import io.github.fletchmckee.liquid.liquefiable
+import io.github.fletchmckee.liquid.rememberLiquidState
+
+val LocalGlassBackdropState = staticCompositionLocalOf {
+ error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
+}
-/**
- * Shared source/sampling state for glass chrome.
- *
- * AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
- * consume [LocalGlassBackdropState] so Liquid sample the same layer behind
- * the dock/search chrome.
- */
@Stable
class GlassBackdropState internal constructor(
- internal val liquidState: Any,
+ internal val liquidState: LiquidState,
)
-val LocalGlassBackdropState = compositionLocalOf { null }
-
@Composable
fun rememberGlassBackdropState(): GlassBackdropState {
- val liquidState = rememberLiquidBackdropHandle()
+ val liquidState = rememberLiquidState()
return remember(liquidState) {
- GlassBackdropState(
- liquidState = liquidState,
- )
+ GlassBackdropState(liquidState)
}
}
@Composable
-fun GlassBackdropSource(
- modifier: Modifier = Modifier,
- state: GlassBackdropState = rememberGlassBackdropState(),
- content: @Composable BoxScope.() -> Unit,
-) {
- CompositionLocalProvider(LocalGlassBackdropState provides state) {
- Box(
- modifier =
- modifier
- .liquidBackdropSource(state),
- content = content,
- )
- }
+fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
+ Box(
+ modifier = modifier.liquefiable(state.liquidState),
+ content = content,
+ )
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
index 9fc07d0..560a711 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
@@ -1,23 +1,55 @@
package dev.ulfrx.recipe.ui.components.glass
-import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
import dev.ulfrx.recipe.ui.theme.RecipeTheme
+import io.github.fletchmckee.liquid.liquefiable
+import io.github.fletchmckee.liquid.liquid
+/**
+ * @param recordAsSource Also register this surface as a Liquid source so other
+ * [GlassSurface]s sampling the same backdrop see this surface's refracted
+ * output — needed for nested glass-on-glass (e.g. a press overlay over the
+ * dock substrate). Liquid's ancestor-exclusion prevents this surface from
+ * sampling itself; outside its bounds it contributes nothing, so siblings
+ * that extend past the source's edges fall back to the shell backdrop
+ * seamlessly.
+ */
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
- border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
- edgeIntensity: Float = 0.05f,
+ glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu,
+ recordAsSource: Boolean = false,
content: @Composable BoxScope.() -> Unit,
) {
val backdropState = LocalGlassBackdropState.current
- LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, content)
+ val shape = RoundedCornerShape(cornerRadius)
+ Box(
+ modifier =
+ modifier
+ .clip(shape)
+ .then(if (recordAsSource) Modifier.liquefiable(backdropState.liquidState) else Modifier)
+ .liquid(backdropState.liquidState) {
+ refraction = glassStyle.refraction
+ curve = glassStyle.curve
+ edge = glassStyle.edge
+ dispersion = glassStyle.dispersion
+ saturation = glassStyle.saturation
+ contrast = glassStyle.contrast
+ frost = glassStyle.frost
+ this.shape = shape
+ this.tint = tint
+ },
+ content = content,
+ )
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt
index 5d70d23..eee0375 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt
@@ -32,26 +32,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
-/**
- * Pill-shaped Liquid-glass text input with iOS-style press feedback.
- *
- * Visual behaviour on press:
- * - Scale 1.0 → 1.05 (subtle — the pill is wide, so even small percentages
- * are readable). Tween 120 ms `FastOutSlowInEasing`, matching the project's
- * standard chrome-interaction timing.
- * - **No** tint change — the keyboard appearing is its own colour event, so
- * additional brightness on the field would compete.
- *
- * Press detection uses `Modifier.pointerInput` with `awaitEachGesture`, but
- * never *consumes* the down event — the wrapped [BasicTextField] still
- * receives the tap and handles focus / IME naturally. The scale animation
- * runs concurrently with the focus request, so the user sees the pill bounce
- * up the moment they touch it, while the keyboard slides into place.
- *
- * Reusable for any glass-style text input. [leadingContent] is a `null`-able
- * slot for a leading icon or other affordance; if null, the field starts at
- * the pill's leading edge.
- */
@Composable
fun GlassTextField(
value: String,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt
deleted file mode 100644
index c802338..0000000
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package dev.ulfrx.recipe.ui.components.glass
-
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import io.github.fletchmckee.liquid.LiquidState
-import io.github.fletchmckee.liquid.liquefiable
-import io.github.fletchmckee.liquid.liquid
-import io.github.fletchmckee.liquid.rememberLiquidState
-
-/**
- * Liquid backend per CONTEXT D-16. The source layer is applied by
- * [GlassBackdropSource] through [liquidBackdropSource], and chrome consumes the
- * same [LiquidState] here.
- */
-@Composable
-internal fun LiquidGlassSurface(
- modifier: Modifier,
- tint: Color,
- cornerRadius: Dp,
- border: BorderStroke?,
- backdropState: GlassBackdropState?,
- edgeIntensity: Float,
- content: @Composable BoxScope.() -> Unit,
-) {
- val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
- val shape = RoundedCornerShape(cornerRadius)
- Box(
- modifier =
- modifier
- .clip(shape)
- .liquid(state) {
- refraction = 0.10f
- curve = 0.5f
- edge = edgeIntensity
- dispersion = 0.05f
- saturation = 0.5f
- contrast = 1.5f
- frost = 10.dp
- this.shape = shape
- this.tint = tint
- }
- .let { if (border != null) it.border(border, shape) else it },
- content = content,
- )
-}
-
-@Composable
-internal fun rememberLiquidBackdropHandle(): Any = rememberLiquidState()
-
-internal fun Modifier.liquidBackdropSource(state: GlassBackdropState): Modifier = liquefiable(state.liquidState as LiquidState)
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 12539ff..5767ae7 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
@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
-import dev.ulfrx.recipe.navigation.BottomBarDestination
+import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
@@ -53,7 +53,7 @@ fun SearchPillRow(
query: String,
isFocused: Boolean,
placeholder: String,
- activeTab: BottomBarDestination,
+ activeTab: DockDestination,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
onFocusGained: () -> Unit,
@@ -98,11 +98,12 @@ fun SearchPillRow(
exit = sideButtonExit,
) {
DockBar(
- destinations = BottomBarDestination.entries,
+ destinations = DockDestination.entries,
active = activeTab,
collapsed = true,
- onTabSelect = { /* unreachable while collapsed */ },
- onCollapsedTap = onClose,
+ // Collapsed dock only emits a re-select of the active tab,
+ // which here means "close the search overlay".
+ onTabSelect = { onClose() },
height = pillHeight,
)
}
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 9860781..992bec1 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
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.ulfrx.recipe.navigation.BottomBarDestination
+import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
@@ -52,7 +52,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
- icon = BottomBarDestination.Pantry.icon,
+ icon = DockDestination.Pantry.icon,
title = stringResource(Res.string.empty_pantry_title),
subtitle = stringResource(Res.string.empty_pantry_subtitle),
)
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 17fe394..bd6a246 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
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.ulfrx.recipe.navigation.BottomBarDestination
+import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
@@ -53,7 +53,7 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
- icon = BottomBarDestination.Recipes.icon,
+ icon = DockDestination.Recipes.icon,
title = stringResource(Res.string.empty_recipes_title),
subtitle = stringResource(Res.string.empty_recipes_subtitle),
)
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 b7ced4f..8bb09d9 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
@@ -1,95 +1,40 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.ExitTransition
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
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.Row
-import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-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.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.glass.LocalGlassBackdropState
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
-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. Owns:
- * - the per-tab navigation back stacks via [TabNavigator]
- * - the shell-wide search affordance via [ShellSearchViewModel]
- *
- * ## Body modes (driven by `searchVm.state.isOpen`)
- *
- * - **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
- * (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()
- // Hoisted so both the body (liquefiable source) and the bottom chrome
- // (liquid samplers) share a single LiquidState. Without this the chrome
- // would fall back to a fresh, sourceless state and render as flat tint.
val backdropState = rememberGlassBackdropState()
- BackHandler(enabled = searchState.isOpen) {
- // Blocked — user must exit search via explicit affordance (dock icon or X).
- }
-
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
Box(
modifier =
@@ -97,7 +42,6 @@ fun AppShell(modifier: Modifier = Modifier) {
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
- // Body — cross-fade between the tab stack and the search overlay.
GlassBackdropSource(
state = backdropState,
modifier = Modifier.fillMaxSize(),
@@ -122,115 +66,19 @@ fun AppShell(modifier: Modifier = Modifier) {
}
}
- // 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",
+ ShellBottomChrome(
+ activeTab = navigator.activeTab,
+ onTabSelect = navigator::selectTab,
+ search = SearchHandlers(
+ state = searchState,
+ onOpen = searchVm::open,
+ onQueryChange = searchVm::onQueryChange,
+ onClose = searchVm::close,
+ onFocus = searchVm::focus,
+ onUnfocus = searchVm::unfocus,
+ ),
+ modifier = Modifier.align(Alignment.BottomCenter),
)
- 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,
- // Exit is instant (no fade-out): the outgoing chrome cell —
- // dock OR search pill row — may still be playing its press
- // animation (the user's finger triggered the tap that switched
- // states). If we also fade it out, the half-faded pressed-up
- // button overlaps visually with the incoming pill, which reads
- // as "two things on screen at once". Instant exit makes the
- // hand-off feel clean while the press animation keeps running
- // off-screen on the now-removed branch.
- transitionSpec = {
- fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
- ExitTransition.None
- },
- 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,
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun DefaultDockRow(
- activeTab: BottomBarDestination,
- onTabSelect: (BottomBarDestination) -> Unit,
- onSearchTap: () -> Unit,
-) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- DockBar(
- destinations = BottomBarDestination.entries,
- active = activeTab,
- collapsed = false,
- onTabSelect = onTabSelect,
- onCollapsedTap = { /* unreachable in default mode */ },
- modifier = Modifier.weight(1f),
- height = 63.dp,
- )
- Box(modifier = Modifier.size(63.dp)) {
- FloatingSearchButton(onClick = onSearchTap)
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt
new file mode 100644
index 0000000..ceb83bb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt
@@ -0,0 +1,174 @@
+package dev.ulfrx.recipe.ui.screens.shell
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExitTransition
+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.togetherWith
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+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.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import dev.ulfrx.recipe.navigation.DockDestination
+import dev.ulfrx.recipe.ui.components.dock.DockBar
+import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
+import dev.ulfrx.recipe.ui.components.search.SearchPillRow
+import dev.ulfrx.recipe.ui.components.search.SearchState
+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_placeholder
+
+/**
+ * Search-side inputs for [ShellBottomChrome] — the live [SearchState] plus the
+ * lambdas the chrome calls back into. Bundled into one holder so the chrome's
+ * parameter list doesn't grow with the VM, and so a `@Preview` can construct
+ * one with no-op lambdas to render any of the three states without a real VM.
+ *
+ * Data class on purpose: structural equality means Compose can skip-recompose
+ * the chrome when [AppShell] re-emits an identical handler bag (lambdas built
+ * from the same VM method references compare equal).
+ */
+data class SearchHandlers(
+ val state: SearchState,
+ val onOpen: () -> Unit,
+ val onQueryChange: (String) -> Unit,
+ val onClose: () -> Unit,
+ val onFocus: () -> Unit,
+ val onUnfocus: () -> Unit,
+)
+
+/**
+ * Bottom chrome for [AppShell]. Owns the dock ↔ search-pill swap and the
+ * three-state geometry choreography (insets, horizontal-padding curve, height
+ * lock, AnimatedContent transition tuning).
+ *
+ * Modes — driven by [search].state:
+ * - **A (closed)** — `[DockBar (full)] [FloatingSearchButton]`
+ * - **B (open, unfocused)** — `[collapsed dock icon] [search pill]`
+ * - **C (open, focused)** — `[search pill (full width)] [X button]`
+ *
+ * Geometry contract (kept here so [AppShell] doesn't need to know any of it):
+ * - The chrome band is height-locked to the dock's 63 dp so the body above
+ * doesn't shift when search opens/closes; the (shorter) search pill is
+ * centred vertically inside that band.
+ * - Horizontal padding animates with state (xl → xl+2 → 8 dp). The narrow C
+ * inset makes the focused input read as a width extension of the keyboard
+ * above it.
+ * - Bottom inset uses navBar/2 (Apple Music pattern — chrome sits close to
+ * the bottom and the home indicator visually overlaps the substrate). When
+ * the IME is up the IME inset wins via `max`.
+ */
+@Composable
+fun ShellBottomChrome(
+ activeTab: DockDestination,
+ onTabSelect: (DockDestination) -> Unit,
+ search: SearchHandlers,
+ modifier: Modifier = Modifier,
+) {
+ val bottomInset =
+ with(LocalDensity.current) {
+ val imePx = WindowInsets.ime.getBottom(this)
+ val navPx = WindowInsets.navigationBars.getBottom(this)
+ maxOf(imePx, navPx / 2).toDp()
+ }
+ val horizontalPadding by animateDpAsState(
+ targetValue =
+ when {
+ !search.state.isOpen -> RecipeTheme.spacing.xl
+ !search.state.isFocused -> RecipeTheme.spacing.xl + 2.dp
+ else -> 8.dp
+ },
+ animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
+ label = "chrome horizontal padding",
+ )
+ Row(
+ modifier =
+ modifier
+ .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 = search.state.isOpen,
+ modifier = Modifier.fillMaxWidth().height(63.dp),
+ contentAlignment = Alignment.Center,
+ // Exit is instant (no fade-out): the outgoing chrome cell — dock
+ // OR search pill row — may still be playing its press animation
+ // (the user's finger triggered the tap that switched states). If
+ // we also fade it out, the half-faded pressed-up button overlaps
+ // visually with the incoming pill, which reads as "two things on
+ // screen at once". Instant exit keeps the hand-off clean while
+ // the press animation finishes off-screen on the removed branch.
+ transitionSpec = {
+ fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
+ ExitTransition.None
+ },
+ label = "AppShell bottom chrome",
+ ) { searchOpen ->
+ if (searchOpen) {
+ SearchPillRow(
+ query = search.state.query,
+ isFocused = search.state.isFocused,
+ placeholder = stringResource(Res.string.search_placeholder),
+ activeTab = activeTab,
+ onQueryChange = search.onQueryChange,
+ onClose = search.onClose,
+ onFocusGained = search.onFocus,
+ onFocusLost = search.onUnfocus,
+ )
+ } else {
+ DockRow(
+ activeTab = activeTab,
+ onTabSelect = onTabSelect,
+ onSearchTap = search.onOpen,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DockRow(
+ activeTab: DockDestination,
+ onTabSelect: (DockDestination) -> Unit,
+ onSearchTap: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ DockBar(
+ destinations = DockDestination.entries,
+ active = activeTab,
+ collapsed = false,
+ onTabSelect = onTabSelect,
+ modifier = Modifier.weight(1f),
+ height = 63.dp,
+ )
+ Box(modifier = Modifier.size(63.dp)) {
+ FloatingSearchButton(onClick = onSearchTap)
+ }
+ }
+}
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 e6ed1cf..90f89f6 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
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.ulfrx.recipe.navigation.BottomBarDestination
+import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
@@ -52,7 +52,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
- icon = BottomBarDestination.Shopping.icon,
+ icon = DockDestination.Shopping.icon,
title = stringResource(Res.string.empty_shopping_title),
subtitle = stringResource(Res.string.empty_shopping_subtitle),
)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt
index d6f3002..f90599e 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt
@@ -13,6 +13,7 @@ public data class RecipeColors(
val content: Color,
val contentMuted: Color,
val accent: Color,
+ val chromeActive: Color,
val separator: Color,
val borderCard: Color,
val destructive: Color,
@@ -26,6 +27,7 @@ public val LightRecipeColors: RecipeColors =
content = Color(0xFF0F1113),
contentMuted = Color(0xFF6B6E73),
accent = Color(0xFFD97757),
+ chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
separator = Color(0xFFE5E1DA),
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
destructive = Color(0xFFC0392B),
@@ -39,6 +41,7 @@ public val DarkRecipeColors: RecipeColors =
content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E),
+ chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
separator = Color(0xFF2A2D31),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368),
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
index 97b7cfc..a9017c1 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
@@ -3,26 +3,34 @@ package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-/**
- * Glass surface defaults (UI-SPEC § Glass / Layout).
- * Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
- * floating button (plan 02.1-05).
- */
-public data class RecipeGlass(
- val borderWidth: Dp,
- val shadowOffsetY: Dp,
- val shadowBlur: Dp,
- val shadowAlphaLight: Float,
- val shadowAlphaDark: Float,
- val blurRadius: Dp,
-)
-
-public val DefaultRecipeGlass: RecipeGlass =
- RecipeGlass(
- borderWidth = 1.dp,
- shadowOffsetY = 8.dp,
- shadowBlur = 24.dp,
- shadowAlphaLight = 0.12f,
- shadowAlphaDark = 0.0f,
- blurRadius = 24.dp,
+data object RecipeGlass {
+ val menu: RecipeGlassStyle = RecipeGlassStyle(
+ refraction = 0.10f,
+ curve = 0.5f,
+ edge = 0.05f,
+ dispersion = 0.05f,
+ saturation = 0.5f,
+ contrast = 1.3f,
+ frost = 15.dp,
)
+
+ val dockPress: RecipeGlassStyle = RecipeGlassStyle(
+ refraction = 0.20f,
+ curve = 0.05f,
+ edge = 0.04f,
+ dispersion = 0.03f,
+ saturation = 0.6f,
+ contrast = 1.8f,
+ frost = 0.dp,
+ )
+}
+
+data class RecipeGlassStyle(
+ val refraction: Float,
+ val curve: Float,
+ val edge: Float,
+ val dispersion: Float,
+ val saturation: Float,
+ val contrast: Float,
+ val frost: Dp,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
index 1eb4326..ce1bbfc 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
@@ -25,9 +25,6 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal =
public val LocalRecipeShapes: ProvidableCompositionLocal =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
-public val LocalRecipeGlass: ProvidableCompositionLocal =
- androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
-
@Composable
public fun RecipeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
@@ -38,29 +35,27 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
LocalRecipeTypography provides DefaultRecipeTypography,
LocalRecipeSpacing provides DefaultRecipeSpacing,
LocalRecipeShapes provides DefaultRecipeShapes,
- LocalRecipeGlass provides DefaultRecipeGlass,
content = content,
)
}
-public object RecipeTheme {
- public val colors: RecipeColors
+object RecipeTheme {
+ val colors: RecipeColors
@Composable @ReadOnlyComposable
get() = LocalRecipeColors.current
- public val typography: RecipeTypography
+ val typography: RecipeTypography
@Composable @ReadOnlyComposable
get() = LocalRecipeTypography.current
- public val spacing: RecipeSpacing
+ val spacing: RecipeSpacing
@Composable @ReadOnlyComposable
get() = LocalRecipeSpacing.current
- public val shapes: RecipeShapes
+ val shapes: RecipeShapes
@Composable @ReadOnlyComposable
get() = LocalRecipeShapes.current
- public val glass: RecipeGlass
- @Composable @ReadOnlyComposable
- get() = LocalRecipeGlass.current
+ val glass: RecipeGlass
+ get() = RecipeGlass
}