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) -->
|
<!-- 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_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_dismiss_keyboard_a11y">Wyczyść i ukryj klawiaturę</string>
|
||||||
<string name="search_clear_a11y">Wyczyść</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) -->
|
<!-- 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_title">Twój plan tygodnia czeka</string>
|
||||||
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</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_recipes
|
||||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||||
|
|
||||||
/**
|
enum class DockDestination(
|
||||||
* 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(
|
|
||||||
val startDestination: Screen,
|
val startDestination: Screen,
|
||||||
val labelRes: StringResource,
|
val labelRes: StringResource,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
@@ -55,7 +41,6 @@ enum class BottomBarDestination(
|
|||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Default landing tab — CONTEXT D-03. */
|
val Default: DockDestination = Planner
|
||||||
val Default: BottomBarDestination = Planner
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,21 +9,21 @@ import androidx.compose.runtime.mutableStateListOf
|
|||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class TabNavigator(
|
class TabNavigator(
|
||||||
initialTab: BottomBarDestination = BottomBarDestination.Default,
|
initialTab: DockDestination = DockDestination.Default,
|
||||||
) {
|
) {
|
||||||
private val backStacks: Map<BottomBarDestination, SnapshotStateList<Screen>> =
|
private val backStacks: Map<DockDestination, SnapshotStateList<Screen>> =
|
||||||
BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) }
|
||||||
|
|
||||||
var activeTab: BottomBarDestination by mutableStateOf(initialTab)
|
var activeTab: DockDestination by mutableStateOf(initialTab)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val activeBackStack: SnapshotStateList<Screen>
|
val activeBackStack: SnapshotStateList<Screen>
|
||||||
get() = backStacks.getValue(activeTab)
|
get() = backStacks.getValue(activeTab)
|
||||||
|
|
||||||
fun backStackFor(tab: BottomBarDestination): SnapshotStateList<Screen> =
|
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> =
|
||||||
backStacks.getValue(tab)
|
backStacks.getValue(tab)
|
||||||
|
|
||||||
fun selectTab(tab: BottomBarDestination) {
|
fun selectTab(tab: DockDestination) {
|
||||||
if (tab == activeTab) {
|
if (tab == activeTab) {
|
||||||
popToRoot(tab)
|
popToRoot(tab)
|
||||||
} else {
|
} else {
|
||||||
@@ -35,14 +35,14 @@ class TabNavigator(
|
|||||||
activeBackStack.add(screen)
|
activeBackStack.add(screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goBack(tab: BottomBarDestination = activeTab) {
|
fun goBack(tab: DockDestination = activeTab) {
|
||||||
val stack = backStacks.getValue(tab)
|
val stack = backStacks.getValue(tab)
|
||||||
if (stack.size > 1) {
|
if (stack.size > 1) {
|
||||||
stack.removeAt(stack.lastIndex)
|
stack.removeAt(stack.lastIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun popToRoot(tab: BottomBarDestination) {
|
private fun popToRoot(tab: DockDestination) {
|
||||||
val stack = backStacks.getValue(tab)
|
val stack = backStacks.getValue(tab)
|
||||||
while (stack.size > 1) {
|
while (stack.size > 1) {
|
||||||
stack.removeAt(stack.lastIndex)
|
stack.removeAt(stack.lastIndex)
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.dock
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.IndicationNodeFactory
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.LocalIndication
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
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.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
@@ -36,411 +30,285 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
import androidx.compose.ui.node.DelegatableNode
|
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.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
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.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.unit.times
|
||||||
import com.composeunstyled.UnstyledIcon
|
import com.composeunstyled.UnstyledIcon
|
||||||
import com.composeunstyled.UnstyledTab
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
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.CircleGlassButton
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.search_close_a11y
|
import recipe.composeapp.generated.resources.dock_expand_a11y
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
private val PressOverlayBleed = 4.dp
|
||||||
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
|
private val PressOverlayVerticalInset = 0.dp
|
||||||
*
|
private val ActiveIndicatorBleed = 4.dp
|
||||||
* Two structurally distinct shapes:
|
private val ActiveIndicatorVerticalInset = 5.dp
|
||||||
* - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs.
|
private val ActiveIndicatorEdgeInset = 5.dp
|
||||||
* Icon + label always shown (D-02); the sliding pill follows the active
|
private val DockTabIconSize = 18.dp
|
||||||
* tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface]
|
private val DockTabIconLabelGap = 2.dp
|
||||||
* with `height / 2` corner radius.
|
private const val PressOverlayScale = 1.22f
|
||||||
* - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton]
|
private const val DockTabLabelFontSizeSp = 11
|
||||||
* showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes
|
private const val DockTabLabelLineHeightSp = 13
|
||||||
* search per D-05).
|
private const val OverlaySlideDurationMs = 200
|
||||||
*
|
private const val OverlayFadeDurationMs = 120
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DockBar(
|
fun DockBar(
|
||||||
destinations: List<BottomBarDestination>,
|
destinations: List<DockDestination>,
|
||||||
active: BottomBarDestination,
|
active: DockDestination,
|
||||||
collapsed: Boolean,
|
collapsed: Boolean,
|
||||||
onTabSelect: (BottomBarDestination) -> Unit,
|
onTabSelect: (DockDestination) -> Unit,
|
||||||
onCollapsedTap: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
height: Dp = 56.dp,
|
height: Dp = 56.dp,
|
||||||
) {
|
) {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
CircleGlassButton(
|
CircleGlassButton(
|
||||||
onClick = onCollapsedTap,
|
onClick = { onTabSelect(active) },
|
||||||
icon = active.icon,
|
icon = active.icon,
|
||||||
contentDescription = stringResource(Res.string.search_close_a11y),
|
contentDescription = stringResource(Res.string.dock_expand_a11y),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
size = height,
|
size = height,
|
||||||
iconTint = RecipeTheme.colors.accent,
|
iconTint = RecipeTheme.colors.accent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Outer Box — no clip — hosts the dock substrate AND the tabs+pill
|
DockBarExpanded(
|
||||||
// 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(
|
|
||||||
destinations = destinations,
|
destinations = destinations,
|
||||||
active = active,
|
active = active,
|
||||||
dockHeight = height,
|
|
||||||
onTabSelect = onTabSelect,
|
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
|
@Composable
|
||||||
private fun ExpandedDockTabs(
|
private fun DockBarExpanded(
|
||||||
destinations: List<BottomBarDestination>,
|
destinations: List<DockDestination>,
|
||||||
active: BottomBarDestination,
|
active: DockDestination,
|
||||||
dockHeight: Dp,
|
onTabSelect: (DockDestination) -> Unit,
|
||||||
onTabSelect: (BottomBarDestination) -> Unit,
|
modifier: Modifier,
|
||||||
|
height: Dp,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
|
||||||
|
var pressedX by remember { mutableStateOf<Float?>(null) }
|
||||||
val tabPositions = remember { mutableStateMapOf<BottomBarDestination, TabBounds>() }
|
var dockWidthPx by remember { mutableStateOf(0f) }
|
||||||
|
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
|
||||||
// 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
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = modifier
|
||||||
Modifier
|
.height(height)
|
||||||
.fillMaxSize()
|
.onSizeChanged { dockWidthPx = it.width.toFloat() }
|
||||||
// sm (8 dp) inner padding gives the pill room to expand up to
|
.pointerInput(destinations) {
|
||||||
// 8 dp past its cell while still leaving the matching 4 dp gap
|
trackDockGesture(
|
||||||
// to the dock's outer rounded edge on first / last tabs.
|
onPressXChange = { pressedX = it },
|
||||||
.padding(horizontal = RecipeTheme.spacing.sm),
|
onCommit = { x ->
|
||||||
) {
|
floorTabIndex(x, tabBounds)?.let { idx ->
|
||||||
if (initialized) {
|
onTabSelect(destinations[idx])
|
||||||
// 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,
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// 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(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
destinations.forEach { dest ->
|
destinations.forEachIndexed { index, destination ->
|
||||||
DockTabCell(
|
val cellBounds = tabBounds[index]
|
||||||
destination = dest,
|
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
|
||||||
isActive = dest == active,
|
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
|
||||||
interactionSource = interactionSources.getValue(dest),
|
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
|
||||||
onClick = { onTabSelect(dest) },
|
bbox.centerPx - cellCenterX
|
||||||
// Uniform weight — cells stay fixed during a tab
|
} else {
|
||||||
// switch. The "active feels bigger" emphasis is
|
0f
|
||||||
// carried by the pill (size + tint), not by
|
}
|
||||||
// resizing the cell.
|
DockTabItem(
|
||||||
modifier =
|
destination = destination,
|
||||||
Modifier
|
isActive = index == activeIndex,
|
||||||
|
contentOffsetPx = contentOffsetPx,
|
||||||
|
onSelect = { onTabSelectFromA11y(destination) },
|
||||||
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
.onGloballyPositioned { coords ->
|
.onGloballyPositioned { coords ->
|
||||||
tabPositions[dest] =
|
onTabBoundsChange(
|
||||||
|
index,
|
||||||
TabBounds(
|
TabBounds(
|
||||||
offsetXPx = coords.positionInParent().x,
|
offsetXPx = coords.positionInParent().x,
|
||||||
widthPx = coords.size.width.toFloat(),
|
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
|
@Composable
|
||||||
private fun DockTabCell(
|
private fun DockTabItem(
|
||||||
destination: BottomBarDestination,
|
destination: DockDestination,
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
interactionSource: MutableInteractionSource,
|
contentOffsetPx: Float,
|
||||||
onClick: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// Both states are fully opaque (alpha 1.0) — chrome foreground must not
|
val label = stringResource(destination.labelRes)
|
||||||
// visually compete with the glass tafla underneath. `contentMuted` reads
|
val a11yLabel = if (isActive) "$label, aktywna" else label
|
||||||
// as transparent over translucent glass, so we use `content` for inactive
|
val tint = RecipeTheme.colors.content
|
||||||
// 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
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = modifier.semantics {
|
||||||
|
role = Role.Tab
|
||||||
|
selected = isActive
|
||||||
|
contentDescription = a11yLabel
|
||||||
|
onClick {
|
||||||
|
onSelect()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
@@ -448,14 +316,120 @@ private fun DockTabCell(
|
|||||||
imageVector = destination.icon,
|
imageVector = destination.icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = tint,
|
tint = tint,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(DockTabIconSize),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(2.dp))
|
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
|
||||||
BasicText(
|
BasicText(
|
||||||
text = labelText,
|
text = label,
|
||||||
style = RecipeTheme.typography.label.copy(color = tint),
|
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 com.composeunstyled.UnstyledIcon
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
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
|
@Composable
|
||||||
fun CircleGlassButton(
|
fun CircleGlassButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|||||||
@@ -3,48 +3,35 @@ package dev.ulfrx.recipe.ui.components.glass
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Stable
|
||||||
class GlassBackdropState internal constructor(
|
class GlassBackdropState internal constructor(
|
||||||
internal val liquidState: Any,
|
internal val liquidState: LiquidState,
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberGlassBackdropState(): GlassBackdropState {
|
fun rememberGlassBackdropState(): GlassBackdropState {
|
||||||
val liquidState = rememberLiquidBackdropHandle()
|
val liquidState = rememberLiquidState()
|
||||||
return remember(liquidState) {
|
return remember(liquidState) {
|
||||||
GlassBackdropState(
|
GlassBackdropState(liquidState)
|
||||||
liquidState = liquidState,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GlassBackdropSource(
|
fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
state: GlassBackdropState = rememberGlassBackdropState(),
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(LocalGlassBackdropState provides state) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = modifier.liquefiable(state.liquidState),
|
||||||
modifier
|
|
||||||
.liquidBackdropSource(state),
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,55 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.glass
|
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.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
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
|
@Composable
|
||||||
fun GlassSurface(
|
fun GlassSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||||
cornerRadius: Dp = 28.dp,
|
cornerRadius: Dp = 28.dp,
|
||||||
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu,
|
||||||
edgeIntensity: Float = 0.05f,
|
recordAsSource: Boolean = false,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val backdropState = LocalGlassBackdropState.current
|
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 androidx.compose.ui.unit.dp
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
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
|
@Composable
|
||||||
fun GlassTextField(
|
fun GlassTextField(
|
||||||
value: String,
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import 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.dock.DockBar
|
||||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
@@ -53,7 +53,7 @@ fun SearchPillRow(
|
|||||||
query: String,
|
query: String,
|
||||||
isFocused: Boolean,
|
isFocused: Boolean,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
activeTab: BottomBarDestination,
|
activeTab: DockDestination,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onFocusGained: () -> Unit,
|
onFocusGained: () -> Unit,
|
||||||
@@ -98,11 +98,12 @@ fun SearchPillRow(
|
|||||||
exit = sideButtonExit,
|
exit = sideButtonExit,
|
||||||
) {
|
) {
|
||||||
DockBar(
|
DockBar(
|
||||||
destinations = BottomBarDestination.entries,
|
destinations = DockDestination.entries,
|
||||||
active = activeTab,
|
active = activeTab,
|
||||||
collapsed = true,
|
collapsed = true,
|
||||||
onTabSelect = { /* unreachable while collapsed */ },
|
// Collapsed dock only emits a re-select of the active tab,
|
||||||
onCollapsedTap = onClose,
|
// which here means "close the search overlay".
|
||||||
|
onTabSelect = { onClose() },
|
||||||
height = pillHeight,
|
height = pillHeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -52,7 +52,7 @@ fun PantryScreen(viewModel: PantryViewModel) {
|
|||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = BottomBarDestination.Pantry.icon,
|
icon = DockDestination.Pantry.icon,
|
||||||
title = stringResource(Res.string.empty_pantry_title),
|
title = stringResource(Res.string.empty_pantry_title),
|
||||||
subtitle = stringResource(Res.string.empty_pantry_subtitle),
|
subtitle = stringResource(Res.string.empty_pantry_subtitle),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -53,7 +53,7 @@ fun RecipesScreen(viewModel: RecipesViewModel) {
|
|||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = BottomBarDestination.Recipes.icon,
|
icon = DockDestination.Recipes.icon,
|
||||||
title = stringResource(Res.string.empty_recipes_title),
|
title = stringResource(Res.string.empty_recipes_title),
|
||||||
subtitle = stringResource(Res.string.empty_recipes_subtitle),
|
subtitle = stringResource(Res.string.empty_recipes_subtitle),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,95 +1,40 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.shell
|
package dev.ulfrx.recipe.ui.screens.shell
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
|
||||||
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
import dev.ulfrx.recipe.navigation.RootNavDisplay
|
||||||
import dev.ulfrx.recipe.navigation.TabNavigator
|
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.GlassBackdropSource
|
||||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
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.SearchScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AppShell(modifier: Modifier = Modifier) {
|
fun AppShell(modifier: Modifier = Modifier) {
|
||||||
val navigator = remember { TabNavigator() }
|
val navigator = remember { TabNavigator() }
|
||||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
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()
|
val backdropState = rememberGlassBackdropState()
|
||||||
|
|
||||||
BackHandler(enabled = searchState.isOpen) {
|
|
||||||
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -97,7 +42,6 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
// Body — cross-fade between the tab stack and the search overlay.
|
|
||||||
GlassBackdropSource(
|
GlassBackdropSource(
|
||||||
state = backdropState,
|
state = backdropState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -122,115 +66,19 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom chrome — Apple-Music-style: don't respect the full nav-bar
|
ShellBottomChrome(
|
||||||
// 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(
|
|
||||||
activeTab = navigator.activeTab,
|
activeTab = navigator.activeTab,
|
||||||
onTabSelect = navigator::selectTab,
|
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.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -52,7 +52,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
|||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = BottomBarDestination.Shopping.icon,
|
icon = DockDestination.Shopping.icon,
|
||||||
title = stringResource(Res.string.empty_shopping_title),
|
title = stringResource(Res.string.empty_shopping_title),
|
||||||
subtitle = stringResource(Res.string.empty_shopping_subtitle),
|
subtitle = stringResource(Res.string.empty_shopping_subtitle),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public data class RecipeColors(
|
|||||||
val content: Color,
|
val content: Color,
|
||||||
val contentMuted: Color,
|
val contentMuted: Color,
|
||||||
val accent: Color,
|
val accent: Color,
|
||||||
|
val chromeActive: Color,
|
||||||
val separator: Color,
|
val separator: Color,
|
||||||
val borderCard: Color,
|
val borderCard: Color,
|
||||||
val destructive: Color,
|
val destructive: Color,
|
||||||
@@ -26,6 +27,7 @@ public val LightRecipeColors: RecipeColors =
|
|||||||
content = Color(0xFF0F1113),
|
content = Color(0xFF0F1113),
|
||||||
contentMuted = Color(0xFF6B6E73),
|
contentMuted = Color(0xFF6B6E73),
|
||||||
accent = Color(0xFFD97757),
|
accent = Color(0xFFD97757),
|
||||||
|
chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f),
|
||||||
separator = Color(0xFFE5E1DA),
|
separator = Color(0xFFE5E1DA),
|
||||||
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f),
|
||||||
destructive = Color(0xFFC0392B),
|
destructive = Color(0xFFC0392B),
|
||||||
@@ -39,6 +41,7 @@ public val DarkRecipeColors: RecipeColors =
|
|||||||
content = Color(0xFFF1EFEA),
|
content = Color(0xFFF1EFEA),
|
||||||
contentMuted = Color(0xFF9AA0A6),
|
contentMuted = Color(0xFF9AA0A6),
|
||||||
accent = Color(0xFFE48A6E),
|
accent = Color(0xFFE48A6E),
|
||||||
|
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
|
||||||
separator = Color(0xFF2A2D31),
|
separator = Color(0xFF2A2D31),
|
||||||
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||||
destructive = Color(0xFFE57368),
|
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
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
data object RecipeGlass {
|
||||||
* Glass surface defaults (UI-SPEC § Glass / Layout).
|
val menu: RecipeGlassStyle = RecipeGlassStyle(
|
||||||
* Consumed by GlassSurface (plan 02.1-03) and the dock / search pill /
|
refraction = 0.10f,
|
||||||
* floating button (plan 02.1-05).
|
curve = 0.5f,
|
||||||
*/
|
edge = 0.05f,
|
||||||
public data class RecipeGlass(
|
dispersion = 0.05f,
|
||||||
val borderWidth: Dp,
|
saturation = 0.5f,
|
||||||
val shadowOffsetY: Dp,
|
contrast = 1.3f,
|
||||||
val shadowBlur: Dp,
|
frost = 15.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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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> =
|
public val LocalRecipeShapes: ProvidableCompositionLocal<RecipeShapes> =
|
||||||
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
|
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") }
|
||||||
|
|
||||||
public val LocalRecipeGlass: ProvidableCompositionLocal<RecipeGlass> =
|
|
||||||
androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
public fun RecipeTheme(content: @Composable () -> Unit) {
|
public fun RecipeTheme(content: @Composable () -> Unit) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -38,29 +35,27 @@ public fun RecipeTheme(content: @Composable () -> Unit) {
|
|||||||
LocalRecipeTypography provides DefaultRecipeTypography,
|
LocalRecipeTypography provides DefaultRecipeTypography,
|
||||||
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
LocalRecipeSpacing provides DefaultRecipeSpacing,
|
||||||
LocalRecipeShapes provides DefaultRecipeShapes,
|
LocalRecipeShapes provides DefaultRecipeShapes,
|
||||||
LocalRecipeGlass provides DefaultRecipeGlass,
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public object RecipeTheme {
|
object RecipeTheme {
|
||||||
public val colors: RecipeColors
|
val colors: RecipeColors
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeColors.current
|
get() = LocalRecipeColors.current
|
||||||
|
|
||||||
public val typography: RecipeTypography
|
val typography: RecipeTypography
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeTypography.current
|
get() = LocalRecipeTypography.current
|
||||||
|
|
||||||
public val spacing: RecipeSpacing
|
val spacing: RecipeSpacing
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeSpacing.current
|
get() = LocalRecipeSpacing.current
|
||||||
|
|
||||||
public val shapes: RecipeShapes
|
val shapes: RecipeShapes
|
||||||
@Composable @ReadOnlyComposable
|
@Composable @ReadOnlyComposable
|
||||||
get() = LocalRecipeShapes.current
|
get() = LocalRecipeShapes.current
|
||||||
|
|
||||||
public val glass: RecipeGlass
|
val glass: RecipeGlass
|
||||||
@Composable @ReadOnlyComposable
|
get() = RecipeGlass
|
||||||
get() = LocalRecipeGlass.current
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user