Rework on the dockbar

This commit is contained in:
2026-05-16 23:14:06 +02:00
parent 48b41fd4af
commit ac5bfbc423
18 changed files with 664 additions and 750 deletions

View File

@@ -30,10 +30,12 @@
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
<string name="search_clear_a11y">Wyczyść</string>
<!-- Phase 2.1 — Dock a11y -->
<string name="dock_expand_a11y">Rozwiń pasek nawigacji</string>
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>

View File

@@ -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 leftright 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
}
}

View File

@@ -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, SnapshotStateList<Screen>> =
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
var activeTab: DockDestination by mutableStateOf(initialTab)
private set
val activeBackStack: SnapshotStateList<Screen>
get() = backStacks.getValue(activeTab)
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> =
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)

View File

@@ -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,411 +30,285 @@ 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<BottomBarDestination>,
active: BottomBarDestination,
destinations: List<DockDestination>,
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.
}
ExpandedDockTabs(
DockBarExpanded(
destinations = destinations,
active = active,
dockHeight = height,
onTabSelect = onTabSelect,
modifier = modifier,
height = height,
)
// 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),
),
)
}
}
}
/**
* Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so
* we can drive a `Modifier.offset { IntOffset(...) }` without re-converting
* each frame.
*/
private data class TabBounds(
val offsetXPx: Float,
val widthPx: Float,
)
@Composable
private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
dockHeight: Dp,
onTabSelect: (BottomBarDestination) -> Unit,
private fun DockBarExpanded(
destinations: List<DockDestination>,
active: DockDestination,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier,
height: Dp,
) {
val density = LocalDensity.current
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
// 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
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
var pressedX by remember { mutableStateOf<Float?>(null) }
var dockWidthPx by remember { mutableStateOf(0f) }
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
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,
) {}
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])
}
// 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(
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<Int, TabBounds>,
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<Int, TabBounds>,
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<DockDestination>,
activeIndex: Int,
tabBounds: Map<Int, TabBounds>,
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.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
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
}
DockTabItem(
destination = destination,
isActive = index == activeIndex,
contentOffsetPx = contentOffsetPx,
onSelect = { onTabSelectFromA11y(destination) },
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.onGloballyPositioned { coords ->
tabPositions[dest] =
onTabBoundsChange(
index,
TabBounds(
offsetXPx = coords.positionInParent().x,
widthPx = coords.size.width.toFloat(),
),
)
},
)
}
}
}
}
}
}
/**
* 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,
private fun DockTabItem(
destination: DockDestination,
isActive: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
contentOffsetPx: Float,
onSelect: () -> 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
},
) {
val label = stringResource(destination.labelRes)
val a11yLabel = if (isActive) "$label, aktywna" else label
val tint = RecipeTheme.colors.content
Box(
modifier = Modifier.fillMaxSize(),
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,
) {
@@ -448,14 +316,120 @@ private fun DockTabCell(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(DockTabIconSize),
)
Spacer(modifier = Modifier.size(2.dp))
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
BasicText(
text = labelText,
style = RecipeTheme.typography.label.copy(color = tint),
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, TabBounds>): 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,
)
private data class ActiveIndicatorBbox(
val leftPx: Float,
val rightPx: Float,
) {
val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
val centerPx: Float get() = (leftPx + rightPx) / 2f
}
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)
}

View File

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

View File

@@ -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<GlassBackdropState> {
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<GlassBackdropState?> { 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) {
fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(
modifier =
modifier
.liquidBackdropSource(state),
modifier = modifier.liquefiable(state.liquidState),
content = content,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
)
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(
ShellBottomChrome(
activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab,
onSearchTap = searchVm::open,
search = SearchHandlers(
state = searchState,
onOpen = searchVm::open,
onQueryChange = searchVm::onQueryChange,
onClose = searchVm::close,
onFocus = searchVm::focus,
onUnfocus = searchVm::unfocus,
),
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
}
}
}
@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)
}
}
}

View File

@@ -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)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -25,9 +25,6 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal<RecipeSpacing> =
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
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
}