Restyle tabbar and search UI
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.dock
|
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.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.foundation.IndicationNodeFactory
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.foundation.LocalIndication
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.foundation.interaction.InteractionSource
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
@@ -32,21 +33,23 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.node.DelegatableNode
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composeunstyled.UnstyledButton
|
|
||||||
import com.composeunstyled.UnstyledIcon
|
import com.composeunstyled.UnstyledIcon
|
||||||
import com.composeunstyled.UnstyledTab
|
import com.composeunstyled.UnstyledTab
|
||||||
import com.composeunstyled.UnstyledTabGroup
|
import com.composeunstyled.UnstyledTabGroup
|
||||||
import com.composeunstyled.UnstyledTabList
|
import com.composeunstyled.UnstyledTabList
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
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.components.glass.GlassSurface
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import kotlinx.coroutines.launch
|
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.
|
* 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
|
* Two structurally distinct shapes:
|
||||||
* tab visually emphasized via accent foreground. Capsule shape: 28dp corner radius,
|
* - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
|
||||||
* 56dp height.
|
* Icon + label always shown (D-02); the sliding pill follows the active
|
||||||
* - Collapsed (collapsed=true): single circular cell showing only the active tab's
|
* tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
|
||||||
* icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes
|
* with `height / 2` corner radius.
|
||||||
* [onCollapsedTap] which closes the search per D-05.
|
* - **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
|
* The two shapes are NOT animated between in-place — AppShell already
|
||||||
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
|
* cross-fades the expanded and collapsed instances via its own [AnimatedContent]
|
||||||
* [FastOutSlowInEasing] per UI-SPEC line 198.
|
* when search opens / closes.
|
||||||
*
|
*
|
||||||
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid API calls are
|
* ## Why the substrate is a *sibling* of the pill (not a parent)
|
||||||
* forbidden here per CLAUDE.md non-negotiable #10.
|
|
||||||
*
|
*
|
||||||
* 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
|
@Composable
|
||||||
fun DockBar(
|
fun DockBar(
|
||||||
@@ -82,44 +96,37 @@ fun DockBar(
|
|||||||
onTabSelect: (BottomBarDestination) -> Unit,
|
onTabSelect: (BottomBarDestination) -> Unit,
|
||||||
onCollapsedTap: () -> Unit,
|
onCollapsedTap: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
height: androidx.compose.ui.unit.Dp = 56.dp,
|
height: Dp = 56.dp,
|
||||||
) {
|
) {
|
||||||
GlassSurface(
|
|
||||||
modifier =
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
modifier.size(height)
|
CircleGlassButton(
|
||||||
} else {
|
onClick = onCollapsedTap,
|
||||||
modifier.height(height)
|
icon = active.icon,
|
||||||
}.animateContentSize(
|
contentDescription = stringResource(Res.string.search_close_a11y),
|
||||||
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
|
modifier = modifier,
|
||||||
),
|
|
||||||
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,
|
size = height,
|
||||||
|
iconTint = RecipeTheme.colors.accent,
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
ExpandedDockTabs(
|
||||||
destinations = destinations,
|
destinations = destinations,
|
||||||
active = active,
|
active = active,
|
||||||
|
dockHeight = height,
|
||||||
onTabSelect = onTabSelect,
|
onTabSelect = onTabSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,45 +143,63 @@ private data class TabBounds(
|
|||||||
private fun ExpandedDockTabs(
|
private fun ExpandedDockTabs(
|
||||||
destinations: List<BottomBarDestination>,
|
destinations: List<BottomBarDestination>,
|
||||||
active: BottomBarDestination,
|
active: BottomBarDestination,
|
||||||
|
dockHeight: Dp,
|
||||||
onTabSelect: (BottomBarDestination) -> Unit,
|
onTabSelect: (BottomBarDestination) -> Unit,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
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>() }
|
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
|
// dominates without resizing any other cell. The pill bleeds into the
|
||||||
// 2 dp inter-cell gap and slightly into adjacent cells; inactive icons +
|
// 2 dp inter-cell gap and slightly into adjacent cells; icons + labels
|
||||||
// labels remain on top (z-order), readable above the dark substrate.
|
// remain on top (z-order), readable above the dark substrate.
|
||||||
val pillExpansion = 8.dp
|
val pillExpansion = 8.dp
|
||||||
val pillExpansionPx = with(density) { pillExpansion.toPx() }
|
val pillExpansionPx = with(density) { pillExpansion.toPx() }
|
||||||
|
|
||||||
val pillX = remember { Animatable(0f) }
|
val pillX = remember { Animatable(0f) }
|
||||||
val pillW = 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) }
|
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
|
if (initialized) return@LaunchedEffect
|
||||||
val t = tabPositions[active] ?: return@LaunchedEffect
|
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||||
pillX.snapTo(t.offsetXPx - pillExpansionPx)
|
pillX.snapTo(t.offsetXPx - pillExpansionPx)
|
||||||
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
|
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
|
||||||
initialized = true
|
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
|
if (!initialized) return@LaunchedEffect
|
||||||
val t = tabPositions[active] ?: return@LaunchedEffect
|
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||||
launch {
|
launch {
|
||||||
pillX.animateTo(
|
pillX.animateTo(
|
||||||
targetValue = t.offsetXPx - pillExpansionPx,
|
targetValue = t.offsetXPx - pillExpansionPx,
|
||||||
@@ -189,44 +214,78 @@ private fun ExpandedDockTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UnstyledTabGroup(
|
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
|
||||||
selectedTab = active.name,
|
// FastOutSlowInEasing so all chrome interactions read uniformly.
|
||||||
tabs = destinations.map { it.name },
|
//
|
||||||
modifier = Modifier.fillMaxSize(),
|
// - 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)
|
||||||
|
|
||||||
|
// Pill's resting visual height after the 4 dp inset on all sides.
|
||||||
|
val pillCorner = (dockHeight - 8.dp) / 2
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// sm (8 dp) inner padding gives the active pill room to
|
// sm (8 dp) inner padding gives the pill room to expand up to
|
||||||
// expand up to 8 dp past its cell while still leaving the
|
// 8 dp past its cell while still leaving the matching 4 dp gap
|
||||||
// matching 4 dp gap to the dock's outer rounded edge on
|
// to the dock's outer rounded edge on first / last tabs.
|
||||||
// first / last tabs.
|
|
||||||
.padding(horizontal = RecipeTheme.spacing.sm),
|
.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) {
|
if (initialized) {
|
||||||
Box(
|
// 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 =
|
||||||
Modifier
|
Modifier
|
||||||
.offset { IntOffset(pillX.value.roundToInt(), 0) }
|
.offset { IntOffset(pillX.value.roundToInt(), 0) }
|
||||||
.width(with(density) { pillW.value.toDp() })
|
.width(with(density) { pillW.value.toDp() })
|
||||||
.fillMaxHeight()
|
.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)
|
.padding(4.dp)
|
||||||
.background(
|
.scale(pillScale),
|
||||||
Color.Black.copy(alpha = 0.3f),
|
cornerRadius = pillCorner,
|
||||||
RoundedCornerShape(50),
|
tint = pillTint,
|
||||||
),
|
border = null,
|
||||||
)
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab row on top — icons + labels are drawn over the pill so the
|
// Tab row on top — icons + labels are drawn over the pill so the
|
||||||
// active tab's foreground (accent) reads against the dark inset.
|
// 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(
|
UnstyledTabList(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
@@ -236,10 +295,12 @@ private fun ExpandedDockTabs(
|
|||||||
DockTabCell(
|
DockTabCell(
|
||||||
destination = dest,
|
destination = dest,
|
||||||
isActive = dest == active,
|
isActive = dest == active,
|
||||||
|
interactionSource = interactionSources.getValue(dest),
|
||||||
onClick = { onTabSelect(dest) },
|
onClick = { onTabSelect(dest) },
|
||||||
// Uniform weight — cells stay fixed during a tab
|
// Uniform weight — cells stay fixed during a tab
|
||||||
// switch. The active-feels-bigger emphasis is carried
|
// switch. The "active feels bigger" emphasis is
|
||||||
// entirely by the dark pill behind the icon + label.
|
// carried by the pill (size + tint), not by
|
||||||
|
// resizing the cell.
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -255,26 +316,42 @@ private fun ExpandedDockTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Composable
|
||||||
private fun DockTabCell(
|
private fun DockTabCell(
|
||||||
destination: BottomBarDestination,
|
destination: BottomBarDestination,
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
|
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
|
||||||
val labelText = stringResource(destination.labelRes)
|
val labelText = stringResource(destination.labelRes)
|
||||||
val a11ySuffix = if (isActive) ", aktywna" else ""
|
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(
|
UnstyledTab(
|
||||||
key = destination.name,
|
key = destination.name,
|
||||||
selected = isActive,
|
selected = isActive,
|
||||||
onSelected = onClick,
|
onSelected = onClick,
|
||||||
activateOnFocus = false,
|
activateOnFocus = false,
|
||||||
|
interactionSource = interactionSource,
|
||||||
shape = RoundedCornerShape(50),
|
shape = RoundedCornerShape(50),
|
||||||
backgroundColor = Color.Transparent,
|
backgroundColor = Color.Transparent,
|
||||||
contentPadding = PaddingValues(0.dp),
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +1,30 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.dock
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
import com.composables.icons.lucide.Search
|
||||||
import com.composeunstyled.UnstyledButton
|
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||||
import com.composeunstyled.UnstyledIcon
|
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.search_open_a11y
|
import recipe.composeapp.generated.resources.search_open_a11y
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 44dp circular Liquid-glass button per UI-SPEC line 181.
|
* 63 dp circular Liquid-glass search button rendered in the dock's trailing
|
||||||
*
|
* slot. Behaviour is delegated to [CircleGlassButton] — this file just locks
|
||||||
* Visible only on Recipes + Pantry tabs (D-06 — gated by AppShell, not here).
|
* the icon, size, and a11y label for the search-affordance role.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FloatingSearchButton(
|
fun FloatingSearchButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
GlassSurface(
|
CircleGlassButton(
|
||||||
modifier = modifier.size(63.dp),
|
|
||||||
cornerRadius = 31.5.dp,
|
|
||||||
) {
|
|
||||||
UnstyledButton(
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
contentPadding = PaddingValues(0.dp),
|
icon = Lucide.Search,
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
UnstyledIcon(
|
|
||||||
imageVector = Lucide.Search,
|
|
||||||
contentDescription = stringResource(Res.string.search_open_a11y),
|
contentDescription = stringResource(Res.string.search_open_a11y),
|
||||||
tint = RecipeTheme.colors.content,
|
modifier = modifier,
|
||||||
modifier = Modifier.size(24.dp),
|
size = 63.dp,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.search
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -16,11 +20,9 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import com.composeunstyled.UnstyledButton
|
|
||||||
import com.composeunstyled.UnstyledIcon
|
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
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 dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -69,12 +71,32 @@ fun SearchPillRow(
|
|||||||
if (!isFocused) focusManager.clearFocus()
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
DockBar(
|
||||||
destinations = BottomBarDestination.entries,
|
destinations = BottomBarDestination.entries,
|
||||||
active = activeTab,
|
active = activeTab,
|
||||||
@@ -94,7 +116,11 @@ fun SearchPillRow(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
height = pillHeight,
|
height = pillHeight,
|
||||||
)
|
)
|
||||||
if (isFocused) {
|
AnimatedVisibility(
|
||||||
|
visible = isFocused,
|
||||||
|
enter = sideButtonEnter,
|
||||||
|
exit = sideButtonExit,
|
||||||
|
) {
|
||||||
DismissSearchKeyboardButton(
|
DismissSearchKeyboardButton(
|
||||||
onClick = onFocusLost,
|
onClick = onFocusLost,
|
||||||
size = pillHeight,
|
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).
|
* unfocuses the search field and clears the query (returns to State B).
|
||||||
|
* Press feedback (scale + tint) is owned by [CircleGlassButton].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DismissSearchKeyboardButton(
|
private fun DismissSearchKeyboardButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
size: Dp,
|
size: Dp,
|
||||||
) {
|
) {
|
||||||
val a11y = stringResource(Res.string.search_dismiss_keyboard_a11y)
|
CircleGlassButton(
|
||||||
GlassSurface(
|
|
||||||
modifier = Modifier.size(size),
|
|
||||||
cornerRadius = size / 2,
|
|
||||||
) {
|
|
||||||
UnstyledButton(
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
contentPadding = PaddingValues(0.dp),
|
icon = Lucide.X,
|
||||||
modifier = Modifier.fillMaxSize(),
|
contentDescription = stringResource(Res.string.search_dismiss_keyboard_a11y),
|
||||||
) {
|
size = size,
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
UnstyledIcon(
|
|
||||||
imageVector = Lucide.X,
|
|
||||||
contentDescription = a11y,
|
|
||||||
tint = RecipeTheme.colors.content,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,21 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.search
|
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.layout.size
|
||||||
import androidx.compose.foundation.text.BasicText
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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 androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
import com.composables.icons.lucide.Search
|
||||||
import com.composeunstyled.UnstyledIcon
|
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
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline bottom search pill per CONTEXT D-09 + UI-SPEC line 182.
|
* Inline bottom search pill. Delegates layout, press feedback, and text input
|
||||||
*
|
* to the generic [GlassTextField]; locks the leading icon to `Lucide.Search`
|
||||||
* Geometry: 44dp height, 22dp corner radius (full-pill at 44dp).
|
* so this file expresses "this is the app's search affordance" rather than
|
||||||
* Substrate: [GlassSurface] with [RecipeTheme.colors.surfaceGlass] tint.
|
* "this is a glass text field".
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchPill(
|
fun SearchPill(
|
||||||
@@ -54,71 +26,20 @@ fun SearchPill(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
height: Dp = 56.dp,
|
height: Dp = 56.dp,
|
||||||
) {
|
) {
|
||||||
GlassSurface(
|
GlassTextField(
|
||||||
modifier = modifier.height(height),
|
value = query,
|
||||||
cornerRadius = height / 2,
|
onValueChange = onQueryChange,
|
||||||
) {
|
placeholder = placeholder,
|
||||||
Row(
|
modifier = modifier,
|
||||||
modifier =
|
height = height,
|
||||||
Modifier
|
onFocusChanged = onFocusChanged,
|
||||||
.fillMaxSize()
|
leadingContent = {
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
|
||||||
) {
|
|
||||||
UnstyledIcon(
|
UnstyledIcon(
|
||||||
imageVector = Lucide.Search,
|
imageVector = Lucide.Search,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = RecipeTheme.colors.contentMuted,
|
tint = RecipeTheme.colors.contentMuted,
|
||||||
modifier = Modifier.size(20.dp),
|
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.shell
|
package dev.ulfrx.recipe.ui.screens.shell
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -159,9 +160,17 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
// inside the same band the dock occupies.
|
// inside the same band the dock occupies.
|
||||||
modifier = Modifier.fillMaxWidth().height(63.dp),
|
modifier = Modifier.fillMaxWidth().height(63.dp),
|
||||||
contentAlignment = Alignment.Center,
|
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 = {
|
transitionSpec = {
|
||||||
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||||
fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing))
|
ExitTransition.None
|
||||||
},
|
},
|
||||||
label = "AppShell bottom chrome",
|
label = "AppShell bottom chrome",
|
||||||
) { searchOpen ->
|
) { searchOpen ->
|
||||||
|
|||||||
Reference in New Issue
Block a user