Restyle tabbar and search UI
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.IndicationNodeFactory
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
@@ -32,21 +33,23 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.node.DelegatableNode
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import com.composeunstyled.UnstyledTab
|
||||
import com.composeunstyled.UnstyledTabGroup
|
||||
import com.composeunstyled.UnstyledTabList
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -58,21 +61,32 @@ import kotlin.math.roundToInt
|
||||
/**
|
||||
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
|
||||
*
|
||||
* - Expanded (collapsed=false): all 4 tabs, icon + label always shown (D-02), active
|
||||
* tab visually emphasized via accent foreground. Capsule shape: 28dp corner radius,
|
||||
* 56dp height.
|
||||
* - Collapsed (collapsed=true): single circular cell showing only the active tab's
|
||||
* icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes
|
||||
* [onCollapsedTap] which closes the search per D-05.
|
||||
* Two structurally distinct shapes:
|
||||
* - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
|
||||
* Icon + label always shown (D-02); the sliding pill follows the active
|
||||
* tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
|
||||
* with `height / 2` corner radius.
|
||||
* - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton]
|
||||
* showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes
|
||||
* search per D-05).
|
||||
*
|
||||
* Single coordinated animation per D-05: the dock animates as one block via
|
||||
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
|
||||
* [FastOutSlowInEasing] per UI-SPEC line 198.
|
||||
* The two shapes are NOT animated between in-place — AppShell already
|
||||
* cross-fades the expanded and collapsed instances via its own [AnimatedContent]
|
||||
* when search opens / closes.
|
||||
*
|
||||
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid API calls are
|
||||
* forbidden here per CLAUDE.md non-negotiable #10.
|
||||
* ## Why the substrate is a *sibling* of the pill (not a parent)
|
||||
*
|
||||
* Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
|
||||
* The pressed-tab affordance ([ExpandedDockTabs]) scales the pill up to 1.20×.
|
||||
* For the pill to visibly extend *past* the dock's rounded contours, it cannot
|
||||
* live inside the dock's [GlassSurface], whose `Modifier.clip(shape)` would
|
||||
* crop it back to the rounded rect. So we wrap both in a no-clip [Box] and
|
||||
* draw the pill as a sibling on top of the substrate — that's also why the
|
||||
* substrate's `content` block is empty here.
|
||||
*
|
||||
* Substrate: only the shared [GlassSurface] / [CircleGlassButton] are used —
|
||||
* direct Liquid API calls are forbidden here per CLAUDE.md non-negotiable #10.
|
||||
*
|
||||
* Touch targets: each tab cell + collapsed toggle is ≥ 44 dp (UI-SPEC line 52, 224).
|
||||
*/
|
||||
@Composable
|
||||
fun DockBar(
|
||||
@@ -82,42 +96,35 @@ fun DockBar(
|
||||
onTabSelect: (BottomBarDestination) -> Unit,
|
||||
onCollapsedTap: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
height: androidx.compose.ui.unit.Dp = 56.dp,
|
||||
height: Dp = 56.dp,
|
||||
) {
|
||||
GlassSurface(
|
||||
modifier =
|
||||
if (collapsed) {
|
||||
modifier.size(height)
|
||||
} else {
|
||||
modifier.height(height)
|
||||
}.animateContentSize(
|
||||
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
|
||||
),
|
||||
cornerRadius = height / 2,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = collapsed,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 250, easing = FastOutSlowInEasing)) togetherWith
|
||||
fadeOut(tween(durationMillis = 250, easing = FastOutSlowInEasing))
|
||||
},
|
||||
label = "DockBar collapse",
|
||||
) { isCollapsed ->
|
||||
if (isCollapsed) {
|
||||
CollapsedDockToggle(
|
||||
active = active,
|
||||
onTap = onCollapsedTap,
|
||||
size = height,
|
||||
)
|
||||
} else {
|
||||
ExpandedDockTabs(
|
||||
destinations = destinations,
|
||||
active = active,
|
||||
onTabSelect = onTabSelect,
|
||||
)
|
||||
if (collapsed) {
|
||||
CircleGlassButton(
|
||||
onClick = onCollapsedTap,
|
||||
icon = active.icon,
|
||||
contentDescription = stringResource(Res.string.search_close_a11y),
|
||||
modifier = modifier,
|
||||
size = height,
|
||||
iconTint = RecipeTheme.colors.accent,
|
||||
)
|
||||
} else {
|
||||
// Outer Box — no clip — hosts the dock substrate AND the tabs+pill
|
||||
// layer so the pressed pill can scale (1.20×) past the dock contours.
|
||||
Box(modifier = modifier.height(height)) {
|
||||
GlassSurface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cornerRadius = height / 2,
|
||||
) {
|
||||
// Empty: the actual pill + tabs live in the sibling overlay
|
||||
// below, outside this GlassSurface's content clip.
|
||||
}
|
||||
|
||||
ExpandedDockTabs(
|
||||
destinations = destinations,
|
||||
active = active,
|
||||
dockHeight = height,
|
||||
onTabSelect = onTabSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,45 +143,63 @@ private data class TabBounds(
|
||||
private fun ExpandedDockTabs(
|
||||
destinations: List<BottomBarDestination>,
|
||||
active: BottomBarDestination,
|
||||
dockHeight: Dp,
|
||||
onTabSelect: (BottomBarDestination) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Per-tab measured bounds, populated as each cell lays out. The floating
|
||||
// pill follows the active tab's entry — when `active` flips, the pill
|
||||
// animates from its current bounds to the new tab's bounds (Apple-Music-
|
||||
// style sliding indicator).
|
||||
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
|
||||
|
||||
// Pill is rendered wider than the cell so the active tab visually
|
||||
// One [MutableInteractionSource] per tab so the pill can react to whichever
|
||||
// tab the finger is *currently* down on — not just the active one.
|
||||
val interactionSources =
|
||||
remember(destinations) {
|
||||
destinations.associateWith { MutableInteractionSource() }
|
||||
}
|
||||
|
||||
// Subscribe to each tab's press state. `forEach` is inline, so the
|
||||
// @Composable scope of this function propagates into the loop body and
|
||||
// `collectIsPressedAsState` is a legal call here. `pressedTab` is a plain
|
||||
// local recomputed per recomposition (cheap; only 4 tabs).
|
||||
var pressedTab: BottomBarDestination? = null
|
||||
destinations.forEach { dest ->
|
||||
val pressed by interactionSources.getValue(dest).collectIsPressedAsState()
|
||||
if (pressed) pressedTab = dest
|
||||
}
|
||||
|
||||
// The pill follows whichever tab the finger is on; it settles back to
|
||||
// the active tab once the press ends (with no click) OR onSelected has
|
||||
// already updated `active` to match (with a click).
|
||||
val pillTargetTab = pressedTab ?: active
|
||||
|
||||
// Pill is rendered wider than the cell so the indicator visually
|
||||
// dominates without resizing any other cell. The pill bleeds into the
|
||||
// 2 dp inter-cell gap and slightly into adjacent cells; inactive icons +
|
||||
// labels remain on top (z-order), readable above the dark substrate.
|
||||
// 2 dp inter-cell gap and slightly into adjacent cells; icons + labels
|
||||
// remain on top (z-order), readable above the dark substrate.
|
||||
val pillExpansion = 8.dp
|
||||
val pillExpansionPx = with(density) { pillExpansion.toPx() }
|
||||
|
||||
val pillX = remember { Animatable(0f) }
|
||||
val pillW = remember { Animatable(0f) }
|
||||
// Pill animates only on `active` change — never per-frame. Two LaunchedEffects:
|
||||
// - keyed on `tabPositions[active]`: handles the very first measurement
|
||||
// (snap, so the pill is at the correct place on cold paint).
|
||||
// - keyed on `active`: handles every subsequent tap (single 200 ms tween,
|
||||
// no re-launch storm). Cells are uniform-weight so the target captured
|
||||
// at click time stays valid for the full animation — nothing moves
|
||||
// under the pill mid-flight.
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(tabPositions[active]) {
|
||||
// First measurement: snap pill to the active cell so cold paint is correct.
|
||||
LaunchedEffect(tabPositions[pillTargetTab]) {
|
||||
if (initialized) return@LaunchedEffect
|
||||
val t = tabPositions[active] ?: return@LaunchedEffect
|
||||
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||
pillX.snapTo(t.offsetXPx - pillExpansionPx)
|
||||
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
// Every subsequent change to the *target* tab — whether triggered by a tap
|
||||
// (active changes) or by a press-down on an inactive tab (pressedTab
|
||||
// changes) — animates the pill across in a single 200 ms tween. Cells are
|
||||
// uniform-weight so the bounds captured here stay valid for the full
|
||||
// animation; nothing moves under the pill mid-flight.
|
||||
LaunchedEffect(pillTargetTab) {
|
||||
if (!initialized) return@LaunchedEffect
|
||||
val t = tabPositions[active] ?: return@LaunchedEffect
|
||||
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||
launch {
|
||||
pillX.animateTo(
|
||||
targetValue = t.offsetXPx - pillExpansionPx,
|
||||
@@ -189,92 +214,144 @@ private fun ExpandedDockTabs(
|
||||
}
|
||||
}
|
||||
|
||||
UnstyledTabGroup(
|
||||
selectedTab = active.name,
|
||||
tabs = destinations.map { it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// sm (8 dp) inner padding gives the active pill room to
|
||||
// expand up to 8 dp past its cell while still leaving the
|
||||
// matching 4 dp gap to the dock's outer rounded edge on
|
||||
// first / last tabs.
|
||||
.padding(horizontal = RecipeTheme.spacing.sm),
|
||||
) {
|
||||
// Floating pill (bottom z-layer). Inset 4dp vertical / 3dp
|
||||
// horizontal from the measured cell bounds — same geometry as the
|
||||
// previous per-cell pill, just rendered once and animated.
|
||||
if (initialized) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset { IntOffset(pillX.value.roundToInt(), 0) }
|
||||
.width(with(density) { pillW.value.toDp() })
|
||||
.fillMaxHeight()
|
||||
// 4dp on all sides — matches the dock's inner
|
||||
// sm padding so an edge-tab pill has equal gap
|
||||
// to the outer rounded edge top/bottom AND side.
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(50),
|
||||
),
|
||||
)
|
||||
}
|
||||
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
|
||||
// FastOutSlowInEasing so all chrome interactions read uniformly.
|
||||
//
|
||||
// - Scale 1.0 → 1.35: the pill "lifts" past the dock's outer rounded
|
||||
// contours. The rest pill sits at a 4 dp vertical inset (visual height
|
||||
// = dockHeight − 8 dp). 1.35× grows it by ~10 dp on each side from its
|
||||
// centre, which leaves ~6 dp sticking out above and below the dock —
|
||||
// clearly past the substrate, not hugging the edge.
|
||||
// - Same uniform factor on width preserves the rest pill's shape (a
|
||||
// full capsule, cornerRadius = height/2 scales with the rest of the
|
||||
// rect, so the scaled pill is *the same shape, just bigger*).
|
||||
//
|
||||
// Tint is **not** animated: the pill is always glass — the same
|
||||
// `Color.White @ 0.18` overlay [CircleGlassButton] uses on press —
|
||||
// regardless of state. Active vs inactive is read from the icon + label
|
||||
// colour (accent vs muted), not from a contrasting fill behind them.
|
||||
val isPressActive = pressedTab != null
|
||||
val pillScale by animateFloatAsState(
|
||||
targetValue = if (isPressActive) 1.35f else 1f,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "Dock pill scale",
|
||||
)
|
||||
val pillTint = Color.White.copy(alpha = 0.18f)
|
||||
|
||||
// Tab row on top — icons + labels are drawn over the pill so the
|
||||
// active tab's foreground (accent) reads against the dark inset.
|
||||
UnstyledTabList(
|
||||
// Pill's resting visual height after the 4 dp inset on all sides.
|
||||
val pillCorner = (dockHeight - 8.dp) / 2
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// sm (8 dp) inner padding gives the pill room to expand up to
|
||||
// 8 dp past its cell while still leaving the matching 4 dp gap
|
||||
// to the dock's outer rounded edge on first / last tabs.
|
||||
.padding(horizontal = RecipeTheme.spacing.sm),
|
||||
) {
|
||||
if (initialized) {
|
||||
// The pill itself — a [GlassSurface] so the press-state can morph
|
||||
// from "dark dim" to "glass" by tint alone, smoothly. Drawn FIRST
|
||||
// so the tab list renders on top; .scale() at the end of the chain
|
||||
// grows the pill (including its rounded clip) past the laid-out
|
||||
// bounds with no parent clip to crop it.
|
||||
GlassSurface(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset { IntOffset(pillX.value.roundToInt(), 0) }
|
||||
.width(with(density) { pillW.value.toDp() })
|
||||
.fillMaxHeight()
|
||||
.padding(4.dp)
|
||||
.scale(pillScale),
|
||||
cornerRadius = pillCorner,
|
||||
tint = pillTint,
|
||||
border = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
// Tab row on top — icons + labels are drawn over the pill so the
|
||||
// active tab's foreground (accent) reads against the dark inset, and
|
||||
// the press-glass tint never obscures the pressed cell's icon.
|
||||
//
|
||||
// [NoIndication] override: `UnstyledTab`'s `indication` parameter is
|
||||
// non-nullable (unlike `UnstyledButton`'s), so we can't pass `null` to
|
||||
// suppress the platform state-layer / ripple. The pill IS our press
|
||||
// indication; without this override the platform ripple draws inside
|
||||
// the tab cell *under* the scaled glass pill, reading as a stray dark
|
||||
// tint bleeding through.
|
||||
CompositionLocalProvider(LocalIndication provides NoIndication) {
|
||||
UnstyledTabGroup(
|
||||
selectedTab = active.name,
|
||||
tabs = destinations.map { it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
destinations.forEach { dest ->
|
||||
DockTabCell(
|
||||
destination = dest,
|
||||
isActive = dest == active,
|
||||
onClick = { onTabSelect(dest) },
|
||||
// Uniform weight — cells stay fixed during a tab
|
||||
// switch. The active-feels-bigger emphasis is carried
|
||||
// entirely by the dark pill behind the icon + label.
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onGloballyPositioned { coords ->
|
||||
tabPositions[dest] =
|
||||
TabBounds(
|
||||
offsetXPx = coords.positionInParent().x,
|
||||
widthPx = coords.size.width.toFloat(),
|
||||
)
|
||||
},
|
||||
)
|
||||
UnstyledTabList(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
destinations.forEach { dest ->
|
||||
DockTabCell(
|
||||
destination = dest,
|
||||
isActive = dest == active,
|
||||
interactionSource = interactionSources.getValue(dest),
|
||||
onClick = { onTabSelect(dest) },
|
||||
// Uniform weight — cells stay fixed during a tab
|
||||
// switch. The "active feels bigger" emphasis is
|
||||
// carried by the pill (size + tint), not by
|
||||
// resizing the cell.
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onGloballyPositioned { coords ->
|
||||
tabPositions[dest] =
|
||||
TabBounds(
|
||||
offsetXPx = coords.positionInParent().x,
|
||||
widthPx = coords.size.width.toFloat(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current]
|
||||
* around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't
|
||||
* paint a platform state-layer / ripple inside the cell — that would draw
|
||||
* *under* the scaled-up glass pill and read as a stray tint bleeding through.
|
||||
*
|
||||
* The pill (size + glass tint) IS the press affordance; nothing else needed.
|
||||
*/
|
||||
private object NoIndication : IndicationNodeFactory {
|
||||
override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {}
|
||||
|
||||
override fun hashCode(): Int = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean = other === this
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockTabCell(
|
||||
destination: BottomBarDestination,
|
||||
isActive: Boolean,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
|
||||
val labelText = stringResource(destination.labelRes)
|
||||
val a11ySuffix = if (isActive) ", aktywna" else ""
|
||||
// Cell is just the touch target + foreground (icon + label). The pill
|
||||
// background lives in [ExpandedDockTabs] as a single sliding indicator,
|
||||
// so individual cells stay transparent.
|
||||
UnstyledTab(
|
||||
key = destination.name,
|
||||
selected = isActive,
|
||||
onSelected = onClick,
|
||||
activateOnFocus = false,
|
||||
interactionSource = interactionSource,
|
||||
shape = RoundedCornerShape(50),
|
||||
backgroundColor = Color.Transparent,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
@@ -308,35 +385,3 @@ private fun DockTabCell(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollapsedDockToggle(
|
||||
active: BottomBarDestination,
|
||||
onTap: () -> Unit,
|
||||
size: androidx.compose.ui.unit.Dp = 56.dp,
|
||||
) {
|
||||
val a11yLabel = stringResource(Res.string.search_close_a11y)
|
||||
UnstyledButton(
|
||||
onClick = onTap,
|
||||
shape = RoundedCornerShape(size / 2),
|
||||
backgroundColor = Color.Transparent,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(size / 2))
|
||||
.semantics { contentDescription = a11yLabel },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UnstyledIcon(
|
||||
imageVector = active.icon,
|
||||
contentDescription = null,
|
||||
tint = RecipeTheme.colors.accent,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user