Restyle tabbar and search UI

This commit is contained in:
2026-05-13 23:23:32 +02:00
parent 3296349507
commit 35eea8cfc8
7 changed files with 522 additions and 333 deletions

View File

@@ -1,14 +1,14 @@
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
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.IndicationNodeFactory
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,6 +24,7 @@ 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
@@ -32,21 +33,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.composeunstyled.UnstyledButton
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.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.launch
@@ -58,21 +61,32 @@ import kotlin.math.roundToInt
/**
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
*
* - Expanded (collapsed=false): all 4 tabs, icon + label always shown (D-02), active
* tab visually emphasized via accent foreground. Capsule shape: 28dp corner radius,
* 56dp height.
* - Collapsed (collapsed=true): single circular cell showing only the active tab's
* icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes
* [onCollapsedTap] which closes the search per D-05.
* 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).
*
* Single coordinated animation per D-05: the dock animates as one block via
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
* [FastOutSlowInEasing] per UI-SPEC line 198.
* 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.
*
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid API calls are
* forbidden here per CLAUDE.md non-negotiable #10.
* ## Why the substrate is a *sibling* of the pill (not a parent)
*
* Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
* 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).
*/
@Composable
fun DockBar(
@@ -82,42 +96,35 @@ fun DockBar(
onTabSelect: (BottomBarDestination) -> Unit,
onCollapsedTap: () -> Unit,
modifier: Modifier = Modifier,
height: androidx.compose.ui.unit.Dp = 56.dp,
height: Dp = 56.dp,
) {
GlassSurface(
modifier =
if (collapsed) {
modifier.size(height)
} else {
modifier.height(height)
}.animateContentSize(
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
),
cornerRadius = height / 2,
) {
AnimatedContent(
targetState = collapsed,
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
transitionSpec = {
fadeIn(tween(durationMillis = 250, easing = FastOutSlowInEasing)) togetherWith
fadeOut(tween(durationMillis = 250, easing = FastOutSlowInEasing))
},
label = "DockBar collapse",
) { isCollapsed ->
if (isCollapsed) {
CollapsedDockToggle(
active = active,
onTap = onCollapsedTap,
size = height,
)
} else {
ExpandedDockTabs(
destinations = destinations,
active = active,
onTabSelect = onTabSelect,
)
if (collapsed) {
CircleGlassButton(
onClick = onCollapsedTap,
icon = active.icon,
contentDescription = stringResource(Res.string.search_close_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)) {
GlassSurface(
modifier = Modifier.fillMaxSize(),
cornerRadius = height / 2,
) {
// Empty: the actual pill + tabs live in the sibling overlay
// below, outside this GlassSurface's content clip.
}
ExpandedDockTabs(
destinations = destinations,
active = active,
dockHeight = height,
onTabSelect = onTabSelect,
)
}
}
}
@@ -136,45 +143,63 @@ private data class TabBounds(
private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
dockHeight: Dp,
onTabSelect: (BottomBarDestination) -> Unit,
) {
val density = LocalDensity.current
// Per-tab measured bounds, populated as each cell lays out. The floating
// pill follows the active tab's entry — when `active` flips, the pill
// animates from its current bounds to the new tab's bounds (Apple-Music-
// style sliding indicator).
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
// Pill is rendered wider than the cell so the active tab visually
// 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; inactive icons +
// labels remain on top (z-order), readable above the dark substrate.
// 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) }
// Pill animates only on `active` change — never per-frame. Two LaunchedEffects:
// - keyed on `tabPositions[active]`: handles the very first measurement
// (snap, so the pill is at the correct place on cold paint).
// - keyed on `active`: handles every subsequent tap (single 200 ms tween,
// no re-launch storm). Cells are uniform-weight so the target captured
// at click time stays valid for the full animation — nothing moves
// under the pill mid-flight.
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(tabPositions[active]) {
// First measurement: snap pill to the active cell so cold paint is correct.
LaunchedEffect(tabPositions[pillTargetTab]) {
if (initialized) return@LaunchedEffect
val t = tabPositions[active] ?: return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
pillX.snapTo(t.offsetXPx - pillExpansionPx)
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
initialized = true
}
LaunchedEffect(active) {
// 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[active] ?: return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
launch {
pillX.animateTo(
targetValue = t.offsetXPx - pillExpansionPx,
@@ -189,92 +214,144 @@ private fun ExpandedDockTabs(
}
}
UnstyledTabGroup(
selectedTab = active.name,
tabs = destinations.map { it.name },
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
// sm (8 dp) inner padding gives the active pill room to
// expand up to 8 dp past its cell while still leaving the
// matching 4 dp gap to the dock's outer rounded edge on
// first / last tabs.
.padding(horizontal = RecipeTheme.spacing.sm),
) {
// Floating pill (bottom z-layer). Inset 4dp vertical / 3dp
// horizontal from the measured cell bounds — same geometry as the
// previous per-cell pill, just rendered once and animated.
if (initialized) {
Box(
modifier =
Modifier
.offset { IntOffset(pillX.value.roundToInt(), 0) }
.width(with(density) { pillW.value.toDp() })
.fillMaxHeight()
// 4dp on all sides — matches the dock's inner
// sm padding so an edge-tab pill has equal gap
// to the outer rounded edge top/bottom AND side.
.padding(4.dp)
.background(
Color.Black.copy(alpha = 0.3f),
RoundedCornerShape(50),
),
)
}
// 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*).
//
// Tint is **not** animated: the pill is always glass — the same
// `Color.White @ 0.18` overlay [CircleGlassButton] uses on press —
// regardless of state. Active vs inactive is read from the icon + label
// colour (accent vs muted), not from a contrasting fill behind them.
val isPressActive = pressedTab != null
val pillScale by animateFloatAsState(
targetValue = if (isPressActive) 1.35f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill scale",
)
val pillTint = Color.White.copy(alpha = 0.18f)
// Tab row on top — icons + labels are drawn over the pill so the
// active tab's foreground (accent) reads against the dark inset.
UnstyledTabList(
// 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),
cornerRadius = pillCorner,
tint = pillTint,
border = null,
) {}
}
// 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(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { dest ->
DockTabCell(
destination = dest,
isActive = dest == active,
onClick = { onTabSelect(dest) },
// Uniform weight — cells stay fixed during a tab
// switch. The active-feels-bigger emphasis is carried
// entirely by the dark pill behind the icon + label.
modifier =
Modifier
.weight(1f)
.onGloballyPositioned { coords ->
tabPositions[dest] =
TabBounds(
offsetXPx = coords.positionInParent().x,
widthPx = coords.size.width.toFloat(),
)
},
)
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(),
)
},
)
}
}
}
}
}
}
/**
* 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,
) {
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
val labelText = stringResource(destination.labelRes)
val a11ySuffix = if (isActive) ", aktywna" else ""
// Cell is just the touch target + foreground (icon + label). The pill
// background lives in [ExpandedDockTabs] as a single sliding indicator,
// so individual cells stay transparent.
UnstyledTab(
key = destination.name,
selected = isActive,
onSelected = onClick,
activateOnFocus = false,
interactionSource = interactionSource,
shape = RoundedCornerShape(50),
backgroundColor = Color.Transparent,
contentPadding = PaddingValues(0.dp),
@@ -308,35 +385,3 @@ private fun DockTabCell(
}
}
}
@Composable
private fun CollapsedDockToggle(
active: BottomBarDestination,
onTap: () -> Unit,
size: androidx.compose.ui.unit.Dp = 56.dp,
) {
val a11yLabel = stringResource(Res.string.search_close_a11y)
UnstyledButton(
onClick = onTap,
shape = RoundedCornerShape(size / 2),
backgroundColor = Color.Transparent,
contentPadding = PaddingValues(0.dp),
modifier =
Modifier
.size(size)
.clip(RoundedCornerShape(size / 2))
.semantics { contentDescription = a11yLabel },
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = active.icon,
contentDescription = null,
tint = RecipeTheme.colors.accent,
modifier = Modifier.size(24.dp),
)
}
}
}

View File

@@ -1,59 +1,30 @@
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_open_a11y
/**
* 44dp circular Liquid-glass button per UI-SPEC line 181.
*
* Visible only on Recipes + Pantry tabs (D-06 — gated by AppShell, not here).
* Hidden when search is open (also gated by AppShell — see AppShell.kt).
*
* Substrate: [GlassSurface] cornerRadius=22dp = full-circle at 44dp.
* Icon: Lucide search tinted [RecipeTheme.colors.content].
* Accessibility: contentDescription = stringResource(search_open_a11y) per UI-SPEC line 221.
* 63 dp circular Liquid-glass search button rendered in the dock's trailing
* slot. Behaviour is delegated to [CircleGlassButton] — this file just locks
* the icon, size, and a11y label for the search-affordance role.
*/
@Composable
fun FloatingSearchButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
GlassSurface(
modifier = modifier.size(63.dp),
cornerRadius = 31.5.dp,
) {
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = stringResource(Res.string.search_open_a11y),
tint = RecipeTheme.colors.content,
modifier = Modifier.size(24.dp),
)
}
}
}
CircleGlassButton(
onClick = onClick,
icon = Lucide.Search,
contentDescription = stringResource(Res.string.search_open_a11y),
modifier = modifier,
size = 63.dp,
)
}

View File

@@ -0,0 +1,96 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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,
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
iconTint: Color = RecipeTheme.colors.content,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressedTint = Color.White.copy(alpha = 0.18f)
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.15f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton scale",
)
val tint by animateColorAsState(
targetValue = if (isPressed) pressedTint else RecipeTheme.colors.surfaceGlass,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "CircleGlassButton tint",
)
GlassSurface(
modifier =
modifier
.scale(scale)
.size(size),
cornerRadius = size / 2,
tint = tint,
) {
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
interactionSource = interactionSource,
indication = null,
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(iconSize),
)
}
}
}
}

View File

@@ -0,0 +1,137 @@
package dev.ulfrx.recipe.ui.components.glass
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
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,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
height: Dp = 56.dp,
onFocusChanged: (Boolean) -> Unit = {},
leadingContent: (@Composable () -> Unit)? = null,
) {
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.04f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "GlassTextField scale",
)
GlassSurface(
modifier =
modifier
.scale(scale)
.height(height)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isPressed = true
try {
waitForUpOrCancellation()
} finally {
isPressed = false
}
}
},
cornerRadius = height / 2,
) {
Row(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
leadingContent?.invoke()
Box(
modifier =
Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
cursorBrush = SolidColor(RecipeTheme.colors.accent),
singleLine = true,
modifier =
Modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) },
decorationBox = { innerField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) {
BasicText(
text = placeholder,
style =
RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.contentMuted,
),
)
}
innerField()
}
},
)
}
}
}
}

View File

@@ -1,12 +1,16 @@
package dev.ulfrx.recipe.ui.components.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -16,11 +20,9 @@ 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 com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -69,12 +71,32 @@ fun SearchPillRow(
if (!isFocused) focusManager.clearFocus()
}
// Shared spec for the B↔C side-button transitions. expand/shrink animate
// the slot width from 0 ↔ natural, so the pill (weight 1f) smoothly takes
// / yields space rather than snapping when the dock icon / X swap in/out.
// Same 200 ms FastOutSlowInEasing as the chrome's horizontal-padding
// animation in AppShell, so the two move in lockstep.
val sideButtonEnter =
expandHorizontally(tween(durationMillis = 200, easing = FastOutSlowInEasing)) +
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing))
val sideButtonExit =
shrinkHorizontally(tween(durationMillis = 200, easing = FastOutSlowInEasing)) +
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
if (!isFocused) {
AnimatedVisibility(
visible = !isFocused,
// C → B: the collapsed dock icon should already be present at the
// bottom — feels jarring if it visibly slides/fades in while the
// keyboard is still dismissing. Only its exit (B → C) needs to
// smoothly clear the slot for the search pill to grow into.
enter = EnterTransition.None,
exit = sideButtonExit,
) {
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
@@ -94,7 +116,11 @@ fun SearchPillRow(
modifier = Modifier.weight(1f),
height = pillHeight,
)
if (isFocused) {
AnimatedVisibility(
visible = isFocused,
enter = sideButtonEnter,
exit = sideButtonExit,
) {
DismissSearchKeyboardButton(
onClick = onFocusLost,
size = pillHeight,
@@ -104,35 +130,19 @@ fun SearchPillRow(
}
/**
* 45dp circular Liquid-glass X button. Visible only in State C — tapping it
* Circular Liquid-glass X button. Visible only in State C — tapping it
* unfocuses the search field and clears the query (returns to State B).
* Press feedback (scale + tint) is owned by [CircleGlassButton].
*/
@Composable
private fun DismissSearchKeyboardButton(
onClick: () -> Unit,
size: Dp,
) {
val a11y = stringResource(Res.string.search_dismiss_keyboard_a11y)
GlassSurface(
modifier = Modifier.size(size),
cornerRadius = size / 2,
) {
UnstyledButton(
onClick = onClick,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnstyledIcon(
imageVector = Lucide.X,
contentDescription = a11y,
tint = RecipeTheme.colors.content,
modifier = Modifier.size(24.dp),
)
}
}
}
CircleGlassButton(
onClick = onClick,
icon = Lucide.X,
contentDescription = stringResource(Res.string.search_dismiss_keyboard_a11y),
size = size,
)
}

View File

@@ -1,49 +1,21 @@
package dev.ulfrx.recipe.ui.components.search
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.components.glass.GlassTextField
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Inline bottom search pill per CONTEXT D-09 + UI-SPEC line 182.
*
* Geometry: 44dp height, 22dp corner radius (full-pill at 44dp).
* Substrate: [GlassSurface] with [RecipeTheme.colors.surfaceGlass] tint.
*
* Layout (left → right):
* - Leading Lucide search icon, tinted [RecipeTheme.colors.contentMuted].
* - [BasicTextField] for query input (renderless — Material 3 forbidden in shell
* code per UI-SPEC line 31; Compose Unstyled `TextField` was the spec'd primitive
* but `BasicTextField` is a clean equivalent that ships with compose-foundation).
*
* Keyboard avoidance: `Modifier.imePadding()` is applied by the caller (AppShell —
* plan 02.1-05) at the chrome Column level, NOT here, to keep the pill geometry
* decoupled from inset handling.
*
* The text field itself is a standard BasicTextField, so its VoiceOver semantics
* work out of the box.
* Inline bottom search pill. Delegates layout, press feedback, and text input
* to the generic [GlassTextField]; locks the leading icon to `Lucide.Search`
* so this file expresses "this is the app's search affordance" rather than
* "this is a glass text field".
*/
@Composable
fun SearchPill(
@@ -54,71 +26,20 @@ fun SearchPill(
modifier: Modifier = Modifier,
height: Dp = 56.dp,
) {
GlassSurface(
modifier = modifier.height(height),
cornerRadius = height / 2,
) {
Row(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
GlassTextField(
value = query,
onValueChange = onQueryChange,
placeholder = placeholder,
modifier = modifier,
height = height,
onFocusChanged = onFocusChanged,
leadingContent = {
UnstyledIcon(
imageVector = Lucide.Search,
contentDescription = null,
tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(20.dp),
)
Box(
modifier =
Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = query,
onValueChange = onQueryChange,
textStyle = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
cursorBrush = SolidColor(RecipeTheme.colors.accent),
singleLine = true,
modifier =
Modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) },
decorationBox = { innerField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (query.isEmpty()) {
PlaceholderText(
text = placeholder,
color = RecipeTheme.colors.contentMuted,
style = RecipeTheme.typography.body,
)
}
innerField()
}
},
)
}
}
}
}
@Composable
private fun PlaceholderText(
text: String,
color: Color,
style: TextStyle,
) {
BasicText(
text = text,
style = style.copy(color = color),
},
)
}

View File

@@ -1,6 +1,7 @@
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
@@ -159,9 +160,17 @@ fun AppShell(modifier: Modifier = Modifier) {
// 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
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
ExitTransition.None
},
label = "AppShell bottom chrome",
) { searchOpen ->