Rework on the dockbar
This commit is contained in:
@@ -30,10 +30,12 @@
|
||||
|
||||
<!-- Phase 2.1 — Search affordance a11y (UI-10, CONTEXT D-06/D-08) -->
|
||||
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
||||
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
|
||||
<string name="search_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
||||
<string name="search_clear_a11y">Wyczyść</string>
|
||||
|
||||
<!-- Phase 2.1 — Dock a11y -->
|
||||
<string name="dock_expand_a11y">Rozwiń pasek nawigacji</string>
|
||||
|
||||
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
|
||||
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
|
||||
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
|
||||
|
||||
@@ -13,21 +13,7 @@ import recipe.composeapp.generated.resources.shell_tab_planner
|
||||
import recipe.composeapp.generated.resources.shell_tab_recipes
|
||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||
|
||||
/**
|
||||
* The 4 bottom-bar destinations in left→right order per CONTEXT D-03:
|
||||
* Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the
|
||||
* default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal
|
||||
* listing order, which research confirmed is non-binding.
|
||||
*
|
||||
* Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home])
|
||||
* so the shell's [TabNavigator] knows where each tab's back stack starts.
|
||||
*
|
||||
* Search is a shell-wide affordance (see
|
||||
* [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) — it lives outside
|
||||
* the tab destinations entirely. This enum is intentionally minimal: route +
|
||||
* label + icon, nothing about feature affordances.
|
||||
*/
|
||||
enum class BottomBarDestination(
|
||||
enum class DockDestination(
|
||||
val startDestination: Screen,
|
||||
val labelRes: StringResource,
|
||||
val icon: ImageVector,
|
||||
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Default landing tab — CONTEXT D-03. */
|
||||
val Default: BottomBarDestination = Planner
|
||||
val Default: DockDestination = Planner
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,21 @@ import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
@Stable
|
||||
class TabNavigator(
|
||||
initialTab: BottomBarDestination = BottomBarDestination.Default,
|
||||
initialTab: DockDestination = DockDestination.Default,
|
||||
) {
|
||||
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
|
||||
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
|
||||
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||
|
||||
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
|
||||
var activeTab: DockDestination by mutableStateOf(initialTab)
|
||||
private set
|
||||
|
||||
val activeBackStack: SnapshotStateList<Screen>
|
||||
get() = backStacks.getValue(activeTab)
|
||||
|
||||
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
|
||||
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> =
|
||||
backStacks.getValue(tab)
|
||||
|
||||
fun selectTab(tab: BottomBarDestination) {
|
||||
fun selectTab(tab: DockDestination) {
|
||||
if (tab == activeTab) {
|
||||
popToRoot(tab)
|
||||
} else {
|
||||
@@ -35,14 +35,14 @@ class TabNavigator(
|
||||
activeBackStack.add(screen)
|
||||
}
|
||||
|
||||
fun goBack(tab: BottomBarDestination = activeTab) {
|
||||
fun goBack(tab: DockDestination = activeTab) {
|
||||
val stack = backStacks.getValue(tab)
|
||||
if (stack.size > 1) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun popToRoot(tab: BottomBarDestination) {
|
||||
private fun popToRoot(tab: DockDestination) {
|
||||
val stack = backStacks.getValue(tab)
|
||||
while (stack.size > 1) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.IndicationNodeFactory
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -27,7 +22,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
@@ -36,411 +30,285 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.node.DelegatableNode
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.times
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import com.composeunstyled.UnstyledTab
|
||||
import com.composeunstyled.UnstyledTabGroup
|
||||
import com.composeunstyled.UnstyledTabList
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.search_close_a11y
|
||||
import recipe.composeapp.generated.resources.dock_expand_a11y
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
|
||||
*
|
||||
* Two structurally distinct shapes:
|
||||
* - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
|
||||
* Icon + label always shown (D-02); the sliding pill follows the active
|
||||
* tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
|
||||
* with `height / 2` corner radius.
|
||||
* - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton]
|
||||
* showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes
|
||||
* search per D-05).
|
||||
*
|
||||
* The two shapes are NOT animated between in-place — AppShell already
|
||||
* cross-fades the expanded and collapsed instances via its own [AnimatedContent]
|
||||
* when search opens / closes.
|
||||
*
|
||||
* ## Why the substrate is a *sibling* of the pill (not a parent)
|
||||
*
|
||||
* The pressed-tab affordance ([ExpandedDockTabs]) scales the pill up to 1.20×.
|
||||
* For the pill to visibly extend *past* the dock's rounded contours, it cannot
|
||||
* live inside the dock's [GlassSurface], whose `Modifier.clip(shape)` would
|
||||
* crop it back to the rounded rect. So we wrap both in a no-clip [Box] and
|
||||
* draw the pill as a sibling on top of the substrate — that's also why the
|
||||
* substrate's `content` block is empty here.
|
||||
*
|
||||
* Substrate: only the shared [GlassSurface] / [CircleGlassButton] are used —
|
||||
* direct Liquid API calls are forbidden here per CLAUDE.md non-negotiable #10.
|
||||
*
|
||||
* Touch targets: each tab cell + collapsed toggle is ≥ 44 dp (UI-SPEC line 52, 224).
|
||||
*/
|
||||
private val PressOverlayBleed = 4.dp
|
||||
private val PressOverlayVerticalInset = 0.dp
|
||||
private val ActiveIndicatorBleed = 4.dp
|
||||
private val ActiveIndicatorVerticalInset = 5.dp
|
||||
private val ActiveIndicatorEdgeInset = 5.dp
|
||||
private val DockTabIconSize = 18.dp
|
||||
private val DockTabIconLabelGap = 2.dp
|
||||
private const val PressOverlayScale = 1.22f
|
||||
private const val DockTabLabelFontSizeSp = 11
|
||||
private const val DockTabLabelLineHeightSp = 13
|
||||
private const val OverlaySlideDurationMs = 200
|
||||
private const val OverlayFadeDurationMs = 120
|
||||
|
||||
@Composable
|
||||
fun DockBar(
|
||||
destinations: List<BottomBarDestination>,
|
||||
active: BottomBarDestination,
|
||||
destinations: List<DockDestination>,
|
||||
active: DockDestination,
|
||||
collapsed: Boolean,
|
||||
onTabSelect: (BottomBarDestination) -> Unit,
|
||||
onCollapsedTap: () -> Unit,
|
||||
onTabSelect: (DockDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 56.dp,
|
||||
) {
|
||||
if (collapsed) {
|
||||
CircleGlassButton(
|
||||
onClick = onCollapsedTap,
|
||||
onClick = { onTabSelect(active) },
|
||||
icon = active.icon,
|
||||
contentDescription = stringResource(Res.string.search_close_a11y),
|
||||
contentDescription = stringResource(Res.string.dock_expand_a11y),
|
||||
modifier = modifier,
|
||||
size = height,
|
||||
iconTint = RecipeTheme.colors.accent,
|
||||
)
|
||||
} else {
|
||||
// Outer Box — no clip — hosts the dock substrate AND the tabs+pill
|
||||
// layer so the pressed pill can scale (1.20×) past the dock contours.
|
||||
Box(modifier = modifier.height(height)) {
|
||||
// Substrate. Border is suppressed here so we can re-draw it on
|
||||
// TOP of the pill at the end of the stack — that way the dock's
|
||||
// outline stays visible through the (inner) pill GlassSurface,
|
||||
// especially when the pill is pressed and scales past the dock.
|
||||
GlassSurface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cornerRadius = height / 2,
|
||||
border = null,
|
||||
) {
|
||||
// Empty: the actual pill + tabs live in the sibling overlay
|
||||
// below, outside this GlassSurface's content clip.
|
||||
}
|
||||
|
||||
ExpandedDockTabs(
|
||||
DockBarExpanded(
|
||||
destinations = destinations,
|
||||
active = active,
|
||||
dockHeight = height,
|
||||
onTabSelect = onTabSelect,
|
||||
modifier = modifier,
|
||||
height = height,
|
||||
)
|
||||
|
||||
// Top-z dock outline so the substrate's contour reads even where
|
||||
// the pill overlaps it. Pure hairline (no fill) — purely a draw
|
||||
// marker; doesn't intercept input.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.border(
|
||||
BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
||||
RoundedCornerShape(height / 2),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so
|
||||
* we can drive a `Modifier.offset { IntOffset(...) }` without re-converting
|
||||
* each frame.
|
||||
*/
|
||||
private data class TabBounds(
|
||||
val offsetXPx: Float,
|
||||
val widthPx: Float,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ExpandedDockTabs(
|
||||
destinations: List<BottomBarDestination>,
|
||||
active: BottomBarDestination,
|
||||
dockHeight: Dp,
|
||||
onTabSelect: (BottomBarDestination) -> Unit,
|
||||
private fun DockBarExpanded(
|
||||
destinations: List<DockDestination>,
|
||||
active: DockDestination,
|
||||
onTabSelect: (DockDestination) -> Unit,
|
||||
modifier: Modifier,
|
||||
height: Dp,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
|
||||
|
||||
// One [MutableInteractionSource] per tab so the pill can react to whichever
|
||||
// tab the finger is *currently* down on — not just the active one.
|
||||
val interactionSources =
|
||||
remember(destinations) {
|
||||
destinations.associateWith { MutableInteractionSource() }
|
||||
}
|
||||
|
||||
// Subscribe to each tab's press state. `forEach` is inline, so the
|
||||
// @Composable scope of this function propagates into the loop body and
|
||||
// `collectIsPressedAsState` is a legal call here. `pressedTab` is a plain
|
||||
// local recomputed per recomposition (cheap; only 4 tabs).
|
||||
var pressedTab: BottomBarDestination? = null
|
||||
destinations.forEach { dest ->
|
||||
val pressed by interactionSources.getValue(dest).collectIsPressedAsState()
|
||||
if (pressed) pressedTab = dest
|
||||
}
|
||||
|
||||
// The pill follows whichever tab the finger is on; it settles back to
|
||||
// the active tab once the press ends (with no click) OR onSelected has
|
||||
// already updated `active` to match (with a click).
|
||||
val pillTargetTab = pressedTab ?: active
|
||||
|
||||
// Pill is rendered wider than the cell so the indicator visually
|
||||
// dominates without resizing any other cell. The pill bleeds into the
|
||||
// 2 dp inter-cell gap and slightly into adjacent cells; icons + labels
|
||||
// remain on top (z-order), readable above the dark substrate.
|
||||
val pillExpansion = 8.dp
|
||||
val pillExpansionPx = with(density) { pillExpansion.toPx() }
|
||||
|
||||
val pillX = remember { Animatable(0f) }
|
||||
val pillW = remember { Animatable(0f) }
|
||||
val pillScale = remember { Animatable(1f) }
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
// Drives the pill's tint: while either is true the pill stays translucent
|
||||
// ("glass"); once both go false the pill settles to an opaque resting
|
||||
// tint. `isPressActive` covers the user holding a finger down; the two
|
||||
// `isXxxAnimating` flags cover the X/W slide and the scale-back-down so
|
||||
// the pill stays glassy until the animations have fully settled.
|
||||
var isXWAnimating by remember { mutableStateOf(false) }
|
||||
var isScaleAnimating by remember { mutableStateOf(false) }
|
||||
|
||||
// First measurement: snap pill to the active cell so cold paint is correct.
|
||||
LaunchedEffect(tabPositions[pillTargetTab]) {
|
||||
if (initialized) return@LaunchedEffect
|
||||
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||
pillX.snapTo(t.offsetXPx - pillExpansionPx)
|
||||
pillW.snapTo(t.widthPx + 2f * pillExpansionPx)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
// Every subsequent change to the *target* tab — whether triggered by a tap
|
||||
// (active changes) or by a press-down on an inactive tab (pressedTab
|
||||
// changes) — animates the pill across in a single 200 ms tween. Cells are
|
||||
// uniform-weight so the bounds captured here stay valid for the full
|
||||
// animation; nothing moves under the pill mid-flight.
|
||||
LaunchedEffect(pillTargetTab) {
|
||||
if (!initialized) return@LaunchedEffect
|
||||
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||
isXWAnimating = true
|
||||
try {
|
||||
coroutineScope {
|
||||
launch {
|
||||
pillX.animateTo(
|
||||
targetValue = t.offsetXPx - pillExpansionPx,
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||
)
|
||||
}
|
||||
launch {
|
||||
pillW.animateTo(
|
||||
targetValue = t.widthPx + 2f * pillExpansionPx,
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isXWAnimating = false
|
||||
}
|
||||
}
|
||||
|
||||
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
|
||||
// FastOutSlowInEasing so all chrome interactions read uniformly.
|
||||
//
|
||||
// - Scale 1.0 → 1.35: the pill "lifts" past the dock's outer rounded
|
||||
// contours. The rest pill sits at a 4 dp vertical inset (visual height
|
||||
// = dockHeight − 8 dp). 1.35× grows it by ~10 dp on each side from its
|
||||
// centre, which leaves ~6 dp sticking out above and below the dock —
|
||||
// clearly past the substrate, not hugging the edge.
|
||||
// - Same uniform factor on width preserves the rest pill's shape (a
|
||||
// full capsule, cornerRadius = height/2 scales with the rest of the
|
||||
// rect, so the scaled pill is *the same shape, just bigger*).
|
||||
val isPressActive = pressedTab != null
|
||||
LaunchedEffect(isPressActive) {
|
||||
isScaleAnimating = true
|
||||
try {
|
||||
pillScale.animateTo(
|
||||
targetValue = if (isPressActive) 1.35f else 1f,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
)
|
||||
} finally {
|
||||
isScaleAnimating = false
|
||||
}
|
||||
}
|
||||
|
||||
// Pill is "busy" (and therefore stays glassy) while the user is holding
|
||||
// it OR while it's still animating in any axis. Once everything settles,
|
||||
// it crossfades to an opaque resting tint so the active tab reads as a
|
||||
// clear solid pill rather than a translucent ghost.
|
||||
val isPillBusy = isPressActive || isXWAnimating || isScaleAnimating
|
||||
val pillBusyTint = Color.White.copy(alpha = 0.18f)
|
||||
val pillRestingTint = Color(0xFF44474B)
|
||||
val pillTint by animateColorAsState(
|
||||
targetValue = if (isPillBusy) pillBusyTint else pillRestingTint,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "Dock pill tint",
|
||||
)
|
||||
// Border only reads while the pill is glassy — when the pill settles to
|
||||
// the opaque resting tint it becomes a solid plate and a hairline would
|
||||
// just compete with the dock's outer outline. Animate the stroke's alpha
|
||||
// so the border crossfades in/out together with the tint.
|
||||
val pillBorderTarget = RecipeTheme.colors.borderCard
|
||||
val pillBorderColor by animateColorAsState(
|
||||
targetValue = if (isPillBusy) pillBorderTarget else pillBorderTarget.copy(alpha = 0f),
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "Dock pill border",
|
||||
)
|
||||
// Liquid's `edge` rim is rendered even when the tint is fully opaque (the
|
||||
// lens itself is nullified, but rim lighting still draws). Zero it out in
|
||||
// the resting state — otherwise the pill keeps a visible bright outline
|
||||
// even when we wanted a clean solid plate.
|
||||
val pillEdge by animateFloatAsState(
|
||||
targetValue = if (isPillBusy) 0.05f else 0f,
|
||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
||||
label = "Dock pill edge",
|
||||
)
|
||||
|
||||
// Pill's resting visual height after the 4 dp inset on all sides.
|
||||
val pillCorner = (dockHeight - 8.dp) / 2
|
||||
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
|
||||
var pressedX by remember { mutableStateOf<Float?>(null) }
|
||||
var dockWidthPx by remember { mutableStateOf(0f) }
|
||||
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// sm (8 dp) inner padding gives the pill room to expand up to
|
||||
// 8 dp past its cell while still leaving the matching 4 dp gap
|
||||
// to the dock's outer rounded edge on first / last tabs.
|
||||
.padding(horizontal = RecipeTheme.spacing.sm),
|
||||
) {
|
||||
if (initialized) {
|
||||
// The pill itself — a [GlassSurface] so the press-state can morph
|
||||
// from "dark dim" to "glass" by tint alone, smoothly. Drawn FIRST
|
||||
// so the tab list renders on top; .scale() at the end of the chain
|
||||
// grows the pill (including its rounded clip) past the laid-out
|
||||
// bounds with no parent clip to crop it.
|
||||
GlassSurface(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset { IntOffset(pillX.value.roundToInt(), 0) }
|
||||
.width(with(density) { pillW.value.toDp() })
|
||||
.fillMaxHeight()
|
||||
.padding(4.dp)
|
||||
.scale(pillScale.value),
|
||||
cornerRadius = pillCorner,
|
||||
tint = pillTint,
|
||||
border = BorderStroke(1.dp, pillBorderColor),
|
||||
edgeIntensity = pillEdge,
|
||||
) {}
|
||||
modifier = modifier
|
||||
.height(height)
|
||||
.onSizeChanged { dockWidthPx = it.width.toFloat() }
|
||||
.pointerInput(destinations) {
|
||||
trackDockGesture(
|
||||
onPressXChange = { pressedX = it },
|
||||
onCommit = { x ->
|
||||
floorTabIndex(x, tabBounds)?.let { idx ->
|
||||
onTabSelect(destinations[idx])
|
||||
}
|
||||
|
||||
// Tab row on top — icons + labels are drawn over the pill so the
|
||||
// active tab's foreground (accent) reads against the dark inset, and
|
||||
// the press-glass tint never obscures the pressed cell's icon.
|
||||
//
|
||||
// [NoIndication] override: `UnstyledTab`'s `indication` parameter is
|
||||
// non-nullable (unlike `UnstyledButton`'s), so we can't pass `null` to
|
||||
// suppress the platform state-layer / ripple. The pill IS our press
|
||||
// indication; without this override the platform ripple draws inside
|
||||
// the tab cell *under* the scaled glass pill, reading as a stray dark
|
||||
// tint bleeding through.
|
||||
CompositionLocalProvider(LocalIndication provides NoIndication) {
|
||||
UnstyledTabGroup(
|
||||
selectedTab = active.name,
|
||||
tabs = destinations.map { it.name },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
UnstyledTabList(
|
||||
DockSubstrate(cornerRadius = height / 2)
|
||||
DockActiveIndicatorLayer(
|
||||
activeIndex = activeIndex,
|
||||
visible = pressedX == null,
|
||||
tabBounds = tabBounds,
|
||||
dockWidthPx = dockWidthPx,
|
||||
)
|
||||
DockPressOverlayLayer(
|
||||
pressedX = pressedX,
|
||||
activeIndex = activeIndex,
|
||||
tabBounds = tabBounds,
|
||||
dockWidthPx = dockWidthPx,
|
||||
dockHeight = height,
|
||||
)
|
||||
DockTabRow(
|
||||
destinations = destinations,
|
||||
activeIndex = activeIndex,
|
||||
tabBounds = tabBounds,
|
||||
dockWidthPx = dockWidthPx,
|
||||
onTabSelectFromA11y = onTabSelect,
|
||||
onTabBoundsChange = { index, bounds -> tabBounds[index] = bounds },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockSubstrate(cornerRadius: Dp) {
|
||||
GlassSurface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cornerRadius = cornerRadius,
|
||||
recordAsSource = true,
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockActiveIndicatorLayer(
|
||||
activeIndex: Int,
|
||||
visible: Boolean,
|
||||
tabBounds: Map<Int, TabBounds>,
|
||||
dockWidthPx: Float,
|
||||
) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
|
||||
label = "Dock active indicator alpha",
|
||||
)
|
||||
val bounds = tabBounds[activeIndex] ?: return
|
||||
if (alpha <= 0f || dockWidthPx <= 0f) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
|
||||
.width(with(density) { bbox.widthPx.toDp() })
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = ActiveIndicatorVerticalInset)
|
||||
.alpha(alpha)
|
||||
.background(
|
||||
color = RecipeTheme.colors.chromeActive,
|
||||
shape = RoundedCornerShape(50),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockPressOverlayLayer(
|
||||
pressedX: Float?,
|
||||
activeIndex: Int,
|
||||
tabBounds: Map<Int, TabBounds>,
|
||||
dockWidthPx: Float,
|
||||
dockHeight: Dp,
|
||||
) {
|
||||
val activeBounds = tabBounds[activeIndex] ?: return
|
||||
val activeCenterX = activeBounds.offsetXPx + activeBounds.widthPx / 2f
|
||||
|
||||
val density = LocalDensity.current
|
||||
val bleedPx = with(density) { PressOverlayBleed.toPx() }
|
||||
val overlayWidthPx = activeBounds.widthPx + 2 * bleedPx
|
||||
val centerXMin = overlayWidthPx / 2f
|
||||
val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin)
|
||||
val clampedPressedX = pressedX?.coerceIn(centerXMin, centerXMax)
|
||||
val overlayCenterX = rememberPressOverlayCenterX(clampedPressedX, activeCenterX)
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (pressedX != null) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
|
||||
label = "Dock press overlay alpha",
|
||||
)
|
||||
if (alpha <= 0f || dockWidthPx <= 0f) return
|
||||
|
||||
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
|
||||
val leftPx = overlayCenterX - overlayWidthPx / 2f
|
||||
GlassSurface(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(leftPx.roundToInt(), 0) }
|
||||
.width(with(density) { overlayWidthPx.toDp() })
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = PressOverlayVerticalInset)
|
||||
.scale(PressOverlayScale)
|
||||
.alpha(alpha),
|
||||
cornerRadius = cornerRadius,
|
||||
glassStyle = RecipeTheme.glass.dockPress,
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockTabRow(
|
||||
destinations: List<DockDestination>,
|
||||
activeIndex: Int,
|
||||
tabBounds: Map<Int, TabBounds>,
|
||||
dockWidthPx: Float,
|
||||
onTabSelectFromA11y: (DockDestination) -> Unit,
|
||||
onTabBoundsChange: (Int, TabBounds) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
destinations.forEach { dest ->
|
||||
DockTabCell(
|
||||
destination = dest,
|
||||
isActive = dest == active,
|
||||
interactionSource = interactionSources.getValue(dest),
|
||||
onClick = { onTabSelect(dest) },
|
||||
// Uniform weight — cells stay fixed during a tab
|
||||
// switch. The "active feels bigger" emphasis is
|
||||
// carried by the pill (size + tint), not by
|
||||
// resizing the cell.
|
||||
modifier =
|
||||
Modifier
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val cellBounds = tabBounds[index]
|
||||
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
|
||||
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
|
||||
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
|
||||
bbox.centerPx - cellCenterX
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
DockTabItem(
|
||||
destination = destination,
|
||||
isActive = index == activeIndex,
|
||||
contentOffsetPx = contentOffsetPx,
|
||||
onSelect = { onTabSelectFromA11y(destination) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned { coords ->
|
||||
tabPositions[dest] =
|
||||
onTabBoundsChange(
|
||||
index,
|
||||
TabBounds(
|
||||
offsetXPx = coords.positionInParent().x,
|
||||
widthPx = coords.size.width.toFloat(),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current]
|
||||
* around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't
|
||||
* paint a platform state-layer / ripple inside the cell — that would draw
|
||||
* *under* the scaled-up glass pill and read as a stray tint bleeding through.
|
||||
*
|
||||
* The pill (size + glass tint) IS the press affordance; nothing else needed.
|
||||
*/
|
||||
private object NoIndication : IndicationNodeFactory {
|
||||
override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {}
|
||||
|
||||
override fun hashCode(): Int = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean = other === this
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockTabCell(
|
||||
destination: BottomBarDestination,
|
||||
private fun DockTabItem(
|
||||
destination: DockDestination,
|
||||
isActive: Boolean,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
contentOffsetPx: Float,
|
||||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Both states are fully opaque (alpha 1.0) — chrome foreground must not
|
||||
// visually compete with the glass tafla underneath. `contentMuted` reads
|
||||
// as transparent over translucent glass, so we use `content` for inactive
|
||||
// tabs and rely on `accent` (saturated) to call out the active one.
|
||||
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.content
|
||||
val labelText = stringResource(destination.labelRes)
|
||||
val a11ySuffix = if (isActive) ", aktywna" else ""
|
||||
UnstyledTab(
|
||||
key = destination.name,
|
||||
selected = isActive,
|
||||
onSelected = onClick,
|
||||
activateOnFocus = false,
|
||||
interactionSource = interactionSource,
|
||||
shape = RoundedCornerShape(50),
|
||||
backgroundColor = Color.Transparent,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.semantics {
|
||||
contentDescription = labelText + a11ySuffix
|
||||
},
|
||||
) {
|
||||
val label = stringResource(destination.labelRes)
|
||||
val a11yLabel = if (isActive) "$label, aktywna" else label
|
||||
val tint = RecipeTheme.colors.content
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = modifier.semantics {
|
||||
role = Role.Tab
|
||||
selected = isActive
|
||||
contentDescription = a11yLabel
|
||||
onClick {
|
||||
onSelect()
|
||||
true
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -448,14 +316,120 @@ private fun DockTabCell(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Modifier.size(DockTabIconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
|
||||
BasicText(
|
||||
text = labelText,
|
||||
style = RecipeTheme.typography.label.copy(color = tint),
|
||||
text = label,
|
||||
style = RecipeTheme.typography.label.copy(
|
||||
color = tint,
|
||||
fontSize = DockTabLabelFontSizeSp.sp,
|
||||
lineHeight = DockTabLabelLineHeightSp.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPressOverlayCenterX(
|
||||
pressedCenterX: Float?,
|
||||
activeCenterX: Float,
|
||||
): Float {
|
||||
val animatable = remember { Animatable(activeCenterX) }
|
||||
var wasPressed by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(pressedCenterX, activeCenterX) {
|
||||
when {
|
||||
pressedCenterX == null -> {
|
||||
wasPressed = false
|
||||
animatable.animateTo(
|
||||
targetValue = activeCenterX,
|
||||
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
|
||||
)
|
||||
}
|
||||
!wasPressed -> {
|
||||
wasPressed = true
|
||||
animatable.animateTo(
|
||||
targetValue = pressedCenterX,
|
||||
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
animatable.snapTo(pressedCenterX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return animatable.value
|
||||
}
|
||||
|
||||
private suspend fun PointerInputScope.trackDockGesture(
|
||||
onPressXChange: (Float?) -> Unit,
|
||||
onCommit: (Float) -> Unit,
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
down.consume()
|
||||
val pointerId = down.id
|
||||
onPressXChange(down.position.x)
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == pointerId }
|
||||
if (change == null) {
|
||||
onPressXChange(null)
|
||||
break
|
||||
}
|
||||
if (!change.pressed) {
|
||||
onCommit(change.position.x)
|
||||
onPressXChange(null)
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
if (change.positionChanged()) {
|
||||
onPressXChange(change.position.x)
|
||||
}
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun floorTabIndex(x: Float, bounds: Map<Int, TabBounds>): Int? {
|
||||
if (bounds.isEmpty()) return null
|
||||
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
|
||||
var result = sorted.first().key
|
||||
for (entry in sorted) {
|
||||
if (entry.value.offsetXPx <= x) {
|
||||
result = entry.key
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private data class TabBounds(
|
||||
val offsetXPx: Float,
|
||||
val widthPx: Float,
|
||||
)
|
||||
|
||||
private data class ActiveIndicatorBbox(
|
||||
val leftPx: Float,
|
||||
val rightPx: Float,
|
||||
) {
|
||||
val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
|
||||
val centerPx: Float get() = (leftPx + rightPx) / 2f
|
||||
}
|
||||
|
||||
private fun activeIndicatorBboxFor(
|
||||
cell: TabBounds,
|
||||
dockWidthPx: Float,
|
||||
density: Density,
|
||||
): ActiveIndicatorBbox {
|
||||
val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
|
||||
val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
|
||||
val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
|
||||
val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
|
||||
return ActiveIndicatorBbox(left, right)
|
||||
}
|
||||
|
||||
@@ -24,22 +24,6 @@ import com.composeunstyled.UnstyledButton
|
||||
import com.composeunstyled.UnstyledIcon
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Circular Liquid-glass icon button with iOS-style press feedback.
|
||||
*
|
||||
* Visual behaviour on press:
|
||||
* - Scale 1.0 → 1.15 (whole button briefly grows under the finger).
|
||||
* - Substrate tint shifts from [RecipeTheme.colors.surfaceGlass] to a
|
||||
* translucent white overlay, so the button reads "lit up".
|
||||
*
|
||||
* Both transitions use the same 120 ms tween with [FastOutSlowInEasing], so
|
||||
* the scale and tint move together. Compose's default [androidx.compose.foundation.Indication]
|
||||
* (ripple / state-layer) is suppressed (`indication = null`) — this scale +
|
||||
* tint pair is the project's standard press affordance for circular chrome.
|
||||
*
|
||||
* Used by the dock's floating search button, the search overlay's dismiss
|
||||
* button, and any future round glass action in the chrome family.
|
||||
*/
|
||||
@Composable
|
||||
fun CircleGlassButton(
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -3,48 +3,35 @@ package dev.ulfrx.recipe.ui.components.glass
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.github.fletchmckee.liquid.LiquidState
|
||||
import io.github.fletchmckee.liquid.liquefiable
|
||||
import io.github.fletchmckee.liquid.rememberLiquidState
|
||||
|
||||
val LocalGlassBackdropState = staticCompositionLocalOf<GlassBackdropState> {
|
||||
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared source/sampling state for glass chrome.
|
||||
*
|
||||
* AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
|
||||
* consume [LocalGlassBackdropState] so Liquid sample the same layer behind
|
||||
* the dock/search chrome.
|
||||
*/
|
||||
@Stable
|
||||
class GlassBackdropState internal constructor(
|
||||
internal val liquidState: Any,
|
||||
internal val liquidState: LiquidState,
|
||||
)
|
||||
|
||||
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberGlassBackdropState(): GlassBackdropState {
|
||||
val liquidState = rememberLiquidBackdropHandle()
|
||||
val liquidState = rememberLiquidState()
|
||||
return remember(liquidState) {
|
||||
GlassBackdropState(
|
||||
liquidState = liquidState,
|
||||
)
|
||||
GlassBackdropState(liquidState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassBackdropSource(
|
||||
modifier: Modifier = Modifier,
|
||||
state: GlassBackdropState = rememberGlassBackdropState(),
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides state) {
|
||||
fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.liquidBackdropSource(state),
|
||||
modifier = modifier.liquefiable(state.liquidState),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,55 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import io.github.fletchmckee.liquid.liquefiable
|
||||
import io.github.fletchmckee.liquid.liquid
|
||||
|
||||
/**
|
||||
* @param recordAsSource Also register this surface as a Liquid source so other
|
||||
* [GlassSurface]s sampling the same backdrop see this surface's refracted
|
||||
* output — needed for nested glass-on-glass (e.g. a press overlay over the
|
||||
* dock substrate). Liquid's ancestor-exclusion prevents this surface from
|
||||
* sampling itself; outside its bounds it contributes nothing, so siblings
|
||||
* that extend past the source's edges fall back to the shell backdrop
|
||||
* seamlessly.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||
cornerRadius: Dp = 28.dp,
|
||||
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
||||
edgeIntensity: Float = 0.05f,
|
||||
glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu,
|
||||
recordAsSource: Boolean = false,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val backdropState = LocalGlassBackdropState.current
|
||||
LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, content)
|
||||
val shape = RoundedCornerShape(cornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(shape)
|
||||
.then(if (recordAsSource) Modifier.liquefiable(backdropState.liquidState) else Modifier)
|
||||
.liquid(backdropState.liquidState) {
|
||||
refraction = glassStyle.refraction
|
||||
curve = glassStyle.curve
|
||||
edge = glassStyle.edge
|
||||
dispersion = glassStyle.dispersion
|
||||
saturation = glassStyle.saturation
|
||||
contrast = glassStyle.contrast
|
||||
frost = glassStyle.frost
|
||||
this.shape = shape
|
||||
this.tint = tint
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,26 +32,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
|
||||
/**
|
||||
* Pill-shaped Liquid-glass text input with iOS-style press feedback.
|
||||
*
|
||||
* Visual behaviour on press:
|
||||
* - Scale 1.0 → 1.05 (subtle — the pill is wide, so even small percentages
|
||||
* are readable). Tween 120 ms `FastOutSlowInEasing`, matching the project's
|
||||
* standard chrome-interaction timing.
|
||||
* - **No** tint change — the keyboard appearing is its own colour event, so
|
||||
* additional brightness on the field would compete.
|
||||
*
|
||||
* Press detection uses `Modifier.pointerInput` with `awaitEachGesture`, but
|
||||
* never *consumes* the down event — the wrapped [BasicTextField] still
|
||||
* receives the tap and handles focus / IME naturally. The scale animation
|
||||
* runs concurrently with the focus request, so the user sees the pill bounce
|
||||
* up the moment they touch it, while the keyboard slides into place.
|
||||
*
|
||||
* Reusable for any glass-style text input. [leadingContent] is a `null`-able
|
||||
* slot for a leading icon or other affordance; if null, the field starts at
|
||||
* the pill's leading edge.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassTextField(
|
||||
value: String,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package dev.ulfrx.recipe.ui.components.glass
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.fletchmckee.liquid.LiquidState
|
||||
import io.github.fletchmckee.liquid.liquefiable
|
||||
import io.github.fletchmckee.liquid.liquid
|
||||
import io.github.fletchmckee.liquid.rememberLiquidState
|
||||
|
||||
/**
|
||||
* Liquid backend per CONTEXT D-16. The source layer is applied by
|
||||
* [GlassBackdropSource] through [liquidBackdropSource], and chrome consumes the
|
||||
* same [LiquidState] here.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LiquidGlassSurface(
|
||||
modifier: Modifier,
|
||||
tint: Color,
|
||||
cornerRadius: Dp,
|
||||
border: BorderStroke?,
|
||||
backdropState: GlassBackdropState?,
|
||||
edgeIntensity: Float,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
|
||||
val shape = RoundedCornerShape(cornerRadius)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(shape)
|
||||
.liquid(state) {
|
||||
refraction = 0.10f
|
||||
curve = 0.5f
|
||||
edge = edgeIntensity
|
||||
dispersion = 0.05f
|
||||
saturation = 0.5f
|
||||
contrast = 1.5f
|
||||
frost = 10.dp
|
||||
this.shape = shape
|
||||
this.tint = tint
|
||||
}
|
||||
.let { if (border != null) it.border(border, shape) else it },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberLiquidBackdropHandle(): Any = rememberLiquidState()
|
||||
|
||||
internal fun Modifier.liquidBackdropSource(state: GlassBackdropState): Modifier = liquefiable(state.liquidState as LiquidState)
|
||||
@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
@@ -53,7 +53,7 @@ fun SearchPillRow(
|
||||
query: String,
|
||||
isFocused: Boolean,
|
||||
placeholder: String,
|
||||
activeTab: BottomBarDestination,
|
||||
activeTab: DockDestination,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onFocusGained: () -> Unit,
|
||||
@@ -98,11 +98,12 @@ fun SearchPillRow(
|
||||
exit = sideButtonExit,
|
||||
) {
|
||||
DockBar(
|
||||
destinations = BottomBarDestination.entries,
|
||||
destinations = DockDestination.entries,
|
||||
active = activeTab,
|
||||
collapsed = true,
|
||||
onTabSelect = { /* unreachable while collapsed */ },
|
||||
onCollapsedTap = onClose,
|
||||
// Collapsed dock only emits a re-select of the active tab,
|
||||
// which here means "close the search overlay".
|
||||
onTabSelect = { onClose() },
|
||||
height = pillHeight,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -52,7 +52,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = BottomBarDestination.Pantry.icon,
|
||||
icon = DockDestination.Pantry.icon,
|
||||
title = stringResource(Res.string.empty_pantry_title),
|
||||
subtitle = stringResource(Res.string.empty_pantry_subtitle),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -53,7 +53,7 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = BottomBarDestination.Recipes.icon,
|
||||
icon = DockDestination.Recipes.icon,
|
||||
title = stringResource(Res.string.empty_recipes_title),
|
||||
subtitle = stringResource(Res.string.empty_recipes_subtitle),
|
||||
)
|
||||
|
||||
@@ -1,95 +1,40 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shell
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.backhandler.BackHandler
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||
import dev.ulfrx.recipe.navigation.TabNavigator
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
||||
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.search_placeholder
|
||||
|
||||
/**
|
||||
* Authenticated root composable. Owns:
|
||||
* - the per-tab navigation back stacks via [TabNavigator]
|
||||
* - the shell-wide search affordance via [ShellSearchViewModel]
|
||||
*
|
||||
* ## Body modes (driven by `searchVm.state.isOpen`)
|
||||
*
|
||||
* - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom
|
||||
* chrome is `[DockBar (full)] [FloatingSearchButton]`.
|
||||
* - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom
|
||||
* chrome is [SearchPillRow], whose layout shifts further on `isFocused`
|
||||
* (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt).
|
||||
*
|
||||
* ## Back-press handling
|
||||
*
|
||||
* While search is open, a [BackHandler] consumes the back press as a no-op:
|
||||
* the user must exit search explicitly via the collapsed dock icon (B→A) or X
|
||||
* (C→B). Confirmed product decision — no implicit dismissal while in search.
|
||||
*
|
||||
* ## Why TabNavigator and not the AndroidX NavController
|
||||
* (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See
|
||||
* [RootNavDisplay] for the full rationale.)
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the
|
||||
// latter is overkill for a static "consume back" guard. Revisit when stable.
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppShell(modifier: Modifier = Modifier) {
|
||||
val navigator = remember { TabNavigator() }
|
||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
||||
// Hoisted so both the body (liquefiable source) and the bottom chrome
|
||||
// (liquid samplers) share a single LiquidState. Without this the chrome
|
||||
// would fall back to a fresh, sourceless state and render as flat tint.
|
||||
val backdropState = rememberGlassBackdropState()
|
||||
|
||||
BackHandler(enabled = searchState.isOpen) {
|
||||
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -97,7 +42,6 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
.fillMaxSize()
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
// Body — cross-fade between the tab stack and the search overlay.
|
||||
GlassBackdropSource(
|
||||
state = backdropState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -122,115 +66,19 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
|
||||
// inset (home indicator) for the bottom edge; halve it so chrome sits
|
||||
// close to the bottom and the home indicator visually overlaps the
|
||||
// chrome substrate. When IME is up, use the full IME inset (it's much
|
||||
// larger than navInset/2, so `max` keeps the chrome above the keyboard).
|
||||
val bottomInset =
|
||||
with(LocalDensity.current) {
|
||||
val imePx = WindowInsets.ime.getBottom(this)
|
||||
val navPx = WindowInsets.navigationBars.getBottom(this)
|
||||
maxOf(imePx, navPx / 2).toDp()
|
||||
}
|
||||
// Horizontal chrome padding animates with the search state:
|
||||
// - Closed (dock visible) → xl (24 dp)
|
||||
// - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly
|
||||
// inset from the dock's footprint
|
||||
// - Open, focused (search C) → 8 dp, so the input reads as a width
|
||||
// extension of the keyboard above it
|
||||
val horizontalPadding by animateDpAsState(
|
||||
targetValue =
|
||||
when {
|
||||
!searchState.isOpen -> RecipeTheme.spacing.xl
|
||||
!searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp
|
||||
else -> 8.dp
|
||||
},
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||
label = "chrome horizontal padding",
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
top = RecipeTheme.spacing.sm,
|
||||
bottom = bottomInset + RecipeTheme.spacing.xs,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = searchState.isOpen,
|
||||
// Lock chrome region to the dock's height in both modes so
|
||||
// (a) the body above doesn't shift when search opens / closes,
|
||||
// and (b) the (shorter) search pill is centred vertically
|
||||
// inside the same band the dock occupies.
|
||||
modifier = Modifier.fillMaxWidth().height(63.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
// Exit is instant (no fade-out): the outgoing chrome cell —
|
||||
// dock OR search pill row — may still be playing its press
|
||||
// animation (the user's finger triggered the tap that switched
|
||||
// states). If we also fade it out, the half-faded pressed-up
|
||||
// button overlaps visually with the incoming pill, which reads
|
||||
// as "two things on screen at once". Instant exit makes the
|
||||
// hand-off feel clean while the press animation keeps running
|
||||
// off-screen on the now-removed branch.
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||
ExitTransition.None
|
||||
},
|
||||
label = "AppShell bottom chrome",
|
||||
) { searchOpen ->
|
||||
if (searchOpen) {
|
||||
SearchPillRow(
|
||||
query = searchState.query,
|
||||
isFocused = searchState.isFocused,
|
||||
placeholder = stringResource(Res.string.search_placeholder),
|
||||
activeTab = navigator.activeTab,
|
||||
onQueryChange = searchVm::onQueryChange,
|
||||
onClose = searchVm::close,
|
||||
onFocusGained = searchVm::focus,
|
||||
onFocusLost = searchVm::unfocus,
|
||||
)
|
||||
} else {
|
||||
DefaultDockRow(
|
||||
ShellBottomChrome(
|
||||
activeTab = navigator.activeTab,
|
||||
onTabSelect = navigator::selectTab,
|
||||
onSearchTap = searchVm::open,
|
||||
search = SearchHandlers(
|
||||
state = searchState,
|
||||
onOpen = searchVm::open,
|
||||
onQueryChange = searchVm::onQueryChange,
|
||||
onClose = searchVm::close,
|
||||
onFocus = searchVm::focus,
|
||||
onUnfocus = searchVm::unfocus,
|
||||
),
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultDockRow(
|
||||
activeTab: BottomBarDestination,
|
||||
onTabSelect: (BottomBarDestination) -> Unit,
|
||||
onSearchTap: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DockBar(
|
||||
destinations = BottomBarDestination.entries,
|
||||
active = activeTab,
|
||||
collapsed = false,
|
||||
onTabSelect = onTabSelect,
|
||||
onCollapsedTap = { /* unreachable in default mode */ },
|
||||
modifier = Modifier.weight(1f),
|
||||
height = 63.dp,
|
||||
)
|
||||
Box(modifier = Modifier.size(63.dp)) {
|
||||
FloatingSearchButton(onClick = onSearchTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package dev.ulfrx.recipe.ui.screens.shell
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.search_placeholder
|
||||
|
||||
/**
|
||||
* Search-side inputs for [ShellBottomChrome] — the live [SearchState] plus the
|
||||
* lambdas the chrome calls back into. Bundled into one holder so the chrome's
|
||||
* parameter list doesn't grow with the VM, and so a `@Preview` can construct
|
||||
* one with no-op lambdas to render any of the three states without a real VM.
|
||||
*
|
||||
* Data class on purpose: structural equality means Compose can skip-recompose
|
||||
* the chrome when [AppShell] re-emits an identical handler bag (lambdas built
|
||||
* from the same VM method references compare equal).
|
||||
*/
|
||||
data class SearchHandlers(
|
||||
val state: SearchState,
|
||||
val onOpen: () -> Unit,
|
||||
val onQueryChange: (String) -> Unit,
|
||||
val onClose: () -> Unit,
|
||||
val onFocus: () -> Unit,
|
||||
val onUnfocus: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bottom chrome for [AppShell]. Owns the dock ↔ search-pill swap and the
|
||||
* three-state geometry choreography (insets, horizontal-padding curve, height
|
||||
* lock, AnimatedContent transition tuning).
|
||||
*
|
||||
* Modes — driven by [search].state:
|
||||
* - **A (closed)** — `[DockBar (full)] [FloatingSearchButton]`
|
||||
* - **B (open, unfocused)** — `[collapsed dock icon] [search pill]`
|
||||
* - **C (open, focused)** — `[search pill (full width)] [X button]`
|
||||
*
|
||||
* Geometry contract (kept here so [AppShell] doesn't need to know any of it):
|
||||
* - The chrome band is height-locked to the dock's 63 dp so the body above
|
||||
* doesn't shift when search opens/closes; the (shorter) search pill is
|
||||
* centred vertically inside that band.
|
||||
* - Horizontal padding animates with state (xl → xl+2 → 8 dp). The narrow C
|
||||
* inset makes the focused input read as a width extension of the keyboard
|
||||
* above it.
|
||||
* - Bottom inset uses navBar/2 (Apple Music pattern — chrome sits close to
|
||||
* the bottom and the home indicator visually overlaps the substrate). When
|
||||
* the IME is up the IME inset wins via `max`.
|
||||
*/
|
||||
@Composable
|
||||
fun ShellBottomChrome(
|
||||
activeTab: DockDestination,
|
||||
onTabSelect: (DockDestination) -> Unit,
|
||||
search: SearchHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bottomInset =
|
||||
with(LocalDensity.current) {
|
||||
val imePx = WindowInsets.ime.getBottom(this)
|
||||
val navPx = WindowInsets.navigationBars.getBottom(this)
|
||||
maxOf(imePx, navPx / 2).toDp()
|
||||
}
|
||||
val horizontalPadding by animateDpAsState(
|
||||
targetValue =
|
||||
when {
|
||||
!search.state.isOpen -> RecipeTheme.spacing.xl
|
||||
!search.state.isFocused -> RecipeTheme.spacing.xl + 2.dp
|
||||
else -> 8.dp
|
||||
},
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||
label = "chrome horizontal padding",
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
top = RecipeTheme.spacing.sm,
|
||||
bottom = bottomInset + RecipeTheme.spacing.xs,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = search.state.isOpen,
|
||||
modifier = Modifier.fillMaxWidth().height(63.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
// Exit is instant (no fade-out): the outgoing chrome cell — dock
|
||||
// OR search pill row — may still be playing its press animation
|
||||
// (the user's finger triggered the tap that switched states). If
|
||||
// we also fade it out, the half-faded pressed-up button overlaps
|
||||
// visually with the incoming pill, which reads as "two things on
|
||||
// screen at once". Instant exit keeps the hand-off clean while
|
||||
// the press animation finishes off-screen on the removed branch.
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith
|
||||
ExitTransition.None
|
||||
},
|
||||
label = "AppShell bottom chrome",
|
||||
) { searchOpen ->
|
||||
if (searchOpen) {
|
||||
SearchPillRow(
|
||||
query = search.state.query,
|
||||
isFocused = search.state.isFocused,
|
||||
placeholder = stringResource(Res.string.search_placeholder),
|
||||
activeTab = activeTab,
|
||||
onQueryChange = search.onQueryChange,
|
||||
onClose = search.onClose,
|
||||
onFocusGained = search.onFocus,
|
||||
onFocusLost = search.onUnfocus,
|
||||
)
|
||||
} else {
|
||||
DockRow(
|
||||
activeTab = activeTab,
|
||||
onTabSelect = onTabSelect,
|
||||
onSearchTap = search.onOpen,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DockRow(
|
||||
activeTab: DockDestination,
|
||||
onTabSelect: (DockDestination) -> Unit,
|
||||
onSearchTap: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DockBar(
|
||||
destinations = DockDestination.entries,
|
||||
active = activeTab,
|
||||
collapsed = false,
|
||||
onTabSelect = onTabSelect,
|
||||
modifier = Modifier.weight(1f),
|
||||
height = 63.dp,
|
||||
)
|
||||
Box(modifier = Modifier.size(63.dp)) {
|
||||
FloatingSearchButton(onClick = onSearchTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -52,7 +52,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyState(
|
||||
icon = BottomBarDestination.Shopping.icon,
|
||||
icon = DockDestination.Shopping.icon,
|
||||
title = stringResource(Res.string.empty_shopping_title),
|
||||
subtitle = stringResource(Res.string.empty_shopping_subtitle),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ public data class RecipeColors(
|
||||
val content: Color,
|
||||
val contentMuted: Color,
|
||||
val accent: Color,
|
||||
val chromeActive: Color,
|
||||
val separator: Color,
|
||||
val borderCard: Color,
|
||||
val destructive: Color,
|
||||
@@ -26,6 +27,7 @@ public val LightRecipeColors: RecipeColors =
|
||||
content = Color(0xFF0F1113),
|
||||
contentMuted = Color(0xFF6B6E73),
|
||||
accent = Color(0xFFD97757),
|
||||
chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
|
||||
separator = Color(0xFFE5E1DA),
|
||||
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
||||
destructive = Color(0xFFC0392B),
|
||||
@@ -39,6 +41,7 @@ public val DarkRecipeColors: RecipeColors =
|
||||
content = Color(0xFFF1EFEA),
|
||||
contentMuted = Color(0xFF9AA0A6),
|
||||
accent = Color(0xFFE48A6E),
|
||||
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
|
||||
separator = Color(0xFF2A2D31),
|
||||
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||
destructive = Color(0xFFE57368),
|
||||
|
||||
@@ -3,26 +3,34 @@ package dev.ulfrx.recipe.ui.theme
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Glass surface defaults (UI-SPEC § Glass / Layout).
|
||||
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
|
||||
* floating button (plan 02.1-05).
|
||||
*/
|
||||
public data class RecipeGlass(
|
||||
val borderWidth: Dp,
|
||||
val shadowOffsetY: Dp,
|
||||
val shadowBlur: Dp,
|
||||
val shadowAlphaLight: Float,
|
||||
val shadowAlphaDark: Float,
|
||||
val blurRadius: Dp,
|
||||
)
|
||||
|
||||
public val DefaultRecipeGlass: RecipeGlass =
|
||||
RecipeGlass(
|
||||
borderWidth = 1.dp,
|
||||
shadowOffsetY = 8.dp,
|
||||
shadowBlur = 24.dp,
|
||||
shadowAlphaLight = 0.12f,
|
||||
shadowAlphaDark = 0.0f,
|
||||
blurRadius = 24.dp,
|
||||
data object RecipeGlass {
|
||||
val menu: RecipeGlassStyle = RecipeGlassStyle(
|
||||
refraction = 0.10f,
|
||||
curve = 0.5f,
|
||||
edge = 0.05f,
|
||||
dispersion = 0.05f,
|
||||
saturation = 0.5f,
|
||||
contrast = 1.3f,
|
||||
frost = 15.dp,
|
||||
)
|
||||
|
||||
val dockPress: RecipeGlassStyle = RecipeGlassStyle(
|
||||
refraction = 0.20f,
|
||||
curve = 0.05f,
|
||||
edge = 0.04f,
|
||||
dispersion = 0.03f,
|
||||
saturation = 0.6f,
|
||||
contrast = 1.8f,
|
||||
frost = 0.dp,
|
||||
)
|
||||
}
|
||||
|
||||
data class RecipeGlassStyle(
|
||||
val refraction: Float,
|
||||
val curve: Float,
|
||||
val edge: Float,
|
||||
val dispersion: Float,
|
||||
val saturation: Float,
|
||||
val contrast: Float,
|
||||
val frost: Dp,
|
||||
)
|
||||
|
||||
@@ -25,9 +25,6 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal<RecipeSpacing> =
|
||||
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
|
||||
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
|
||||
|
||||
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
|
||||
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
|
||||
|
||||
@Composable
|
||||
public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -38,29 +35,27 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||
LocalRecipeTypography provides DefaultRecipeTypography,
|
||||
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
||||
LocalRecipeShapes provides DefaultRecipeShapes,
|
||||
LocalRecipeGlass provides DefaultRecipeGlass,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
public object RecipeTheme {
|
||||
public val colors: RecipeColors
|
||||
object RecipeTheme {
|
||||
val colors: RecipeColors
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeColors.current
|
||||
|
||||
public val typography: RecipeTypography
|
||||
val typography: RecipeTypography
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeTypography.current
|
||||
|
||||
public val spacing: RecipeSpacing
|
||||
val spacing: RecipeSpacing
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeSpacing.current
|
||||
|
||||
public val shapes: RecipeShapes
|
||||
val shapes: RecipeShapes
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeShapes.current
|
||||
|
||||
public val glass: RecipeGlass
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalRecipeGlass.current
|
||||
val glass: RecipeGlass
|
||||
get() = RecipeGlass
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user