Rework on the dockbar

This commit is contained in:
2026-05-16 23:14:06 +02:00
parent 48b41fd4af
commit ac5bfbc423
18 changed files with 664 additions and 750 deletions

View File

@@ -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>

View File

@@ -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 leftright 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
} }
} }

View File

@@ -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)

View File

@@ -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,426 +30,406 @@ 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. destinations = destinations,
Box(modifier = modifier.height(height)) { active = active,
// Substrate. Border is suppressed here so we can re-draw it on onTabSelect = onTabSelect,
// TOP of the pill at the end of the stack — that way the dock's modifier = modifier,
// outline stays visible through the (inner) pill GlassSurface, height = height,
// especially when the pill is pressed and scales past the dock. )
GlassSurface( }
modifier = Modifier.fillMaxSize(), }
cornerRadius = height / 2,
border = null, @Composable
) { private fun DockBarExpanded(
// Empty: the actual pill + tabs live in the sibling overlay destinations: List<DockDestination>,
// below, outside this GlassSurface's content clip. active: DockDestination,
onTabSelect: (DockDestination) -> Unit,
modifier: Modifier,
height: Dp,
) {
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
.height(height)
.onSizeChanged { dockWidthPx = it.width.toFloat() }
.pointerInput(destinations) {
trackDockGesture(
onPressXChange = { pressedX = it },
onCommit = { x ->
floorTabIndex(x, tabBounds)?.let { idx ->
onTabSelect(destinations[idx])
}
},
)
},
) {
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.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(
ExpandedDockTabs( destination = destination,
destinations = destinations, isActive = index == activeIndex,
active = active, contentOffsetPx = contentOffsetPx,
dockHeight = height, onSelect = { onTabSelectFromA11y(destination) },
onTabSelect = onTabSelect, modifier = Modifier
) .weight(1f)
.fillMaxHeight()
// Top-z dock outline so the substrate's contour reads even where .onGloballyPositioned { coords ->
// the pill overlaps it. Pure hairline (no fill) — purely a draw onTabBoundsChange(
// marker; doesn't intercept input. index,
Box( TabBounds(
modifier = offsetXPx = coords.positionInParent().x,
Modifier widthPx = coords.size.width.toFloat(),
.fillMaxSize() ),
.border( )
BorderStroke(1.dp, RecipeTheme.colors.borderCard), },
RoundedCornerShape(height / 2),
),
) )
} }
} }
} }
/** @Composable
* Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so private fun DockTabItem(
* we can drive a `Modifier.offset { IntOffset(...) }` without re-converting destination: DockDestination,
* each frame. isActive: Boolean,
*/ contentOffsetPx: Float,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val label = stringResource(destination.labelRes)
val a11yLabel = if (isActive) "$label, aktywna" else label
val tint = RecipeTheme.colors.content
Box(
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,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(DockTabIconSize),
)
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
BasicText(
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( private data class TabBounds(
val offsetXPx: Float, val offsetXPx: Float,
val widthPx: Float, val widthPx: Float,
) )
@Composable private data class ActiveIndicatorBbox(
private fun ExpandedDockTabs( val leftPx: Float,
destinations: List<BottomBarDestination>, val rightPx: Float,
active: BottomBarDestination,
dockHeight: Dp,
onTabSelect: (BottomBarDestination) -> Unit,
) { ) {
val density = LocalDensity.current val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
val centerPx: Float get() = (leftPx + rightPx) / 2f
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
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,
) {}
}
// 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(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { dest ->
DockTabCell(
destination = dest,
isActive = dest == active,
interactionSource = interactionSources.getValue(dest),
onClick = { onTabSelect(dest) },
// Uniform weight — cells stay fixed during a tab
// switch. The "active feels bigger" emphasis is
// carried by the pill (size + tint), not by
// resizing the cell.
modifier =
Modifier
.weight(1f)
.onGloballyPositioned { coords ->
tabPositions[dest] =
TabBounds(
offsetXPx = coords.positionInParent().x,
widthPx = coords.size.width.toFloat(),
)
},
)
}
}
}
}
}
} }
/** private fun activeIndicatorBboxFor(
* No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current] cell: TabBounds,
* around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't dockWidthPx: Float,
* paint a platform state-layer / ripple inside the cell — that would draw density: Density,
* *under* the scaled-up glass pill and read as a stray tint bleeding through. ): ActiveIndicatorBbox {
* val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
* The pill (size + glass tint) IS the press affordance; nothing else needed. val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
*/ val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
private object NoIndication : IndicationNodeFactory { val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {} return ActiveIndicatorBbox(left, right)
override fun hashCode(): Int = 0
override fun equals(other: Any?): Boolean = other === this
}
@Composable
private fun DockTabCell(
destination: BottomBarDestination,
isActive: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// 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
},
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
UnstyledIcon(
imageVector = destination.icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.size(2.dp))
BasicText(
text = labelText,
style = RecipeTheme.typography.label.copy(color = tint),
)
}
}
}
} }

View File

@@ -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,

View File

@@ -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, Box(
state: GlassBackdropState = rememberGlassBackdropState(), modifier = modifier.liquefiable(state.liquidState),
content: @Composable BoxScope.() -> Unit, content = content,
) { )
CompositionLocalProvider(LocalGlassBackdropState provides state) {
Box(
modifier =
modifier
.liquidBackdropSource(state),
content = content,
)
}
} }

View File

@@ -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,
)
} }

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,
) )
} }

View File

@@ -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),
) )

View File

@@ -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),
) )

View File

@@ -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 activeTab = navigator.activeTab,
// close to the bottom and the home indicator visually overlaps the onTabSelect = navigator::selectTab,
// chrome substrate. When IME is up, use the full IME inset (it's much search = SearchHandlers(
// larger than navInset/2, so `max` keeps the chrome above the keyboard). state = searchState,
val bottomInset = onOpen = searchVm::open,
with(LocalDensity.current) { onQueryChange = searchVm::onQueryChange,
val imePx = WindowInsets.ime.getBottom(this) onClose = searchVm::close,
val navPx = WindowInsets.navigationBars.getBottom(this) onFocus = searchVm::focus,
maxOf(imePx, navPx / 2).toDp() onUnfocus = searchVm::unfocus,
} ),
// Horizontal chrome padding animates with the search state: modifier = Modifier.align(Alignment.BottomCenter),
// - 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,
onTabSelect = navigator::selectTab,
onSearchTap = searchVm::open,
)
}
}
}
}
}
}
@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)
} }
} }
} }

View File

@@ -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)
}
}
}

View File

@@ -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),
) )

View File

@@ -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),

View File

@@ -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,
)

View File

@@ -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
} }