From 35eea8cfc8ddeda693fec2c5ac0ebf9947a6a1dc Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Wed, 13 May 2026 23:23:32 +0200 Subject: [PATCH] Restyle tabbar and search UI --- .../recipe/ui/components/dock/DockBar.kt | 377 ++++++++++-------- .../components/dock/FloatingSearchButton.kt | 51 +-- .../ui/components/glass/CircleGlassButton.kt | 96 +++++ .../ui/components/glass/GlassTextField.kt | 137 +++++++ .../ui/components/search/SearchChrome.kt | 76 ++-- .../recipe/ui/components/search/SearchPill.kt | 107 +---- .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 11 +- 7 files changed, 522 insertions(+), 333 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt index ccba32c..65894aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt @@ -1,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, 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() } - // 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), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt index 24a76bc..2af2cdd 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt @@ -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, + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt new file mode 100644 index 0000000..9501a48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt @@ -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), + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt new file mode 100644 index 0000000..47e9acf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt @@ -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() + } + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt index fd3c1eb..12539ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt @@ -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, + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt index 49497a6..c93e649 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt @@ -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), + }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt index e59995c..934831a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt @@ -1,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 ->