diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 0153a12..12b56c0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -30,10 +30,12 @@ Otwórz wyszukiwanie - Zamknij wyszukiwanie Wyczyść i ukryj klawiaturę Wyczyść + + Rozwiń pasek nawigacji + Twój plan tygodnia czeka Wkrótce zobaczysz tu zaplanowane posiłki. diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/DockDestination.kt similarity index 62% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/DockDestination.kt index c770e2a..70a9f29 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/DockDestination.kt @@ -13,21 +13,7 @@ import recipe.composeapp.generated.resources.shell_tab_planner import recipe.composeapp.generated.resources.shell_tab_recipes import recipe.composeapp.generated.resources.shell_tab_shopping -/** - * The 4 bottom-bar destinations in left→right order per CONTEXT D-03: - * Planner / Recipes / Pantry / Shopping. The first entry (Planner) is the - * default landing tab — CONTEXT D-03 departs from REQUIREMENTS' literal - * listing order, which research confirmed is non-binding. - * - * Each destination carries its tab's *root* [Screen] (e.g. [Screen.Planner.Home]) - * so the shell's [TabNavigator] knows where each tab's back stack starts. - * - * Search is a shell-wide affordance (see - * [dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel]) — it lives outside - * the tab destinations entirely. This enum is intentionally minimal: route + - * label + icon, nothing about feature affordances. - */ -enum class BottomBarDestination( +enum class DockDestination( val startDestination: Screen, val labelRes: StringResource, val icon: ImageVector, @@ -55,7 +41,6 @@ enum class BottomBarDestination( ; companion object { - /** Default landing tab — CONTEXT D-03. */ - val Default: BottomBarDestination = Planner + val Default: DockDestination = Planner } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt index 8024ae2..ed49d8a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt @@ -9,21 +9,21 @@ import androidx.compose.runtime.mutableStateListOf @Stable class TabNavigator( - initialTab: BottomBarDestination = BottomBarDestination.Default, + initialTab: DockDestination = DockDestination.Default, ) { - private val backStacks: Map> = - BottomBarDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) } + private val backStacks: Map> = + DockDestination.entries.associateWith { dest -> mutableStateListOf(dest.startDestination) } - var activeTab: BottomBarDestination by mutableStateOf(initialTab) + var activeTab: DockDestination by mutableStateOf(initialTab) private set val activeBackStack: SnapshotStateList get() = backStacks.getValue(activeTab) - fun backStackFor(tab: BottomBarDestination): SnapshotStateList = + fun backStackFor(tab: DockDestination): SnapshotStateList = backStacks.getValue(tab) - fun selectTab(tab: BottomBarDestination) { + fun selectTab(tab: DockDestination) { if (tab == activeTab) { popToRoot(tab) } else { @@ -35,14 +35,14 @@ class TabNavigator( activeBackStack.add(screen) } - fun goBack(tab: BottomBarDestination = activeTab) { + fun goBack(tab: DockDestination = activeTab) { val stack = backStacks.getValue(tab) if (stack.size > 1) { stack.removeAt(stack.lastIndex) } } - private fun popToRoot(tab: BottomBarDestination) { + private fun popToRoot(tab: DockDestination) { val stack = backStacks.getValue(tab) while (stack.size > 1) { stack.removeAt(stack.lastIndex) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt index 63ef519..a9b1ee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt @@ -1,21 +1,16 @@ package dev.ulfrx.recipe.ui.components.dock -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.IndicationNodeFactory -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -27,7 +22,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf @@ -36,426 +30,406 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times import com.composeunstyled.UnstyledIcon -import com.composeunstyled.UnstyledTab -import com.composeunstyled.UnstyledTabGroup -import com.composeunstyled.UnstyledTabList -import dev.ulfrx.recipe.navigation.BottomBarDestination +import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res -import recipe.composeapp.generated.resources.search_close_a11y +import recipe.composeapp.generated.resources.dock_expand_a11y import kotlin.math.roundToInt -/** - * Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180. - * - * Two structurally distinct shapes: - * - **Expanded** (`collapsed=false`): full-width capsule containing the 4 tabs. - * Icon + label always shown (D-02); the sliding pill follows the active - * tab — and whichever tab is *currently pressed*. Substrate: [GlassSurface] - * with `height / 2` corner radius. - * - **Collapsed** (`collapsed=true`): a single circular [CircleGlassButton] - * showing the active tab's icon. Tapping invokes [onCollapsedTap] (closes - * search per D-05). - * - * The two shapes are NOT animated between in-place — AppShell already - * cross-fades the expanded and collapsed instances via its own [AnimatedContent] - * when search opens / closes. - * - * ## Why the substrate is a *sibling* of the pill (not a parent) - * - * The pressed-tab affordance ([ExpandedDockTabs]) scales the pill up to 1.20×. - * For the pill to visibly extend *past* the dock's rounded contours, it cannot - * live inside the dock's [GlassSurface], whose `Modifier.clip(shape)` would - * crop it back to the rounded rect. So we wrap both in a no-clip [Box] and - * draw the pill as a sibling on top of the substrate — that's also why the - * substrate's `content` block is empty here. - * - * Substrate: only the shared [GlassSurface] / [CircleGlassButton] are used — - * direct Liquid API calls are forbidden here per CLAUDE.md non-negotiable #10. - * - * Touch targets: each tab cell + collapsed toggle is ≥ 44 dp (UI-SPEC line 52, 224). - */ +private val PressOverlayBleed = 4.dp +private val PressOverlayVerticalInset = 0.dp +private val ActiveIndicatorBleed = 4.dp +private val ActiveIndicatorVerticalInset = 5.dp +private val ActiveIndicatorEdgeInset = 5.dp +private val DockTabIconSize = 18.dp +private val DockTabIconLabelGap = 2.dp +private const val PressOverlayScale = 1.22f +private const val DockTabLabelFontSizeSp = 11 +private const val DockTabLabelLineHeightSp = 13 +private const val OverlaySlideDurationMs = 200 +private const val OverlayFadeDurationMs = 120 + @Composable fun DockBar( - destinations: List, - active: BottomBarDestination, + destinations: List, + active: DockDestination, collapsed: Boolean, - onTabSelect: (BottomBarDestination) -> Unit, - onCollapsedTap: () -> Unit, + onTabSelect: (DockDestination) -> Unit, modifier: Modifier = Modifier, height: Dp = 56.dp, ) { if (collapsed) { CircleGlassButton( - onClick = onCollapsedTap, + onClick = { onTabSelect(active) }, icon = active.icon, - contentDescription = stringResource(Res.string.search_close_a11y), + contentDescription = stringResource(Res.string.dock_expand_a11y), modifier = modifier, size = height, iconTint = RecipeTheme.colors.accent, ) } else { - // Outer Box — no clip — hosts the dock substrate AND the tabs+pill - // layer so the pressed pill can scale (1.20×) past the dock contours. - Box(modifier = modifier.height(height)) { - // Substrate. Border is suppressed here so we can re-draw it on - // TOP of the pill at the end of the stack — that way the dock's - // outline stays visible through the (inner) pill GlassSurface, - // especially when the pill is pressed and scales past the dock. - GlassSurface( - modifier = Modifier.fillMaxSize(), - cornerRadius = height / 2, - border = null, - ) { - // Empty: the actual pill + tabs live in the sibling overlay - // below, outside this GlassSurface's content clip. + DockBarExpanded( + destinations = destinations, + active = active, + onTabSelect = onTabSelect, + modifier = modifier, + height = height, + ) + } +} + +@Composable +private fun DockBarExpanded( + destinations: List, + active: DockDestination, + onTabSelect: (DockDestination) -> Unit, + modifier: Modifier, + height: Dp, +) { + val tabBounds = remember { mutableStateMapOf() } + var pressedX by remember { mutableStateOf(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, + 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, + 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, + activeIndex: Int, + tabBounds: Map, + 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 } - - ExpandedDockTabs( - destinations = destinations, - active = active, - dockHeight = height, - onTabSelect = onTabSelect, - ) - - // 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), - ), + DockTabItem( + destination = destination, + isActive = index == activeIndex, + contentOffsetPx = contentOffsetPx, + onSelect = { onTabSelectFromA11y(destination) }, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .onGloballyPositioned { coords -> + onTabBoundsChange( + index, + TabBounds( + offsetXPx = coords.positionInParent().x, + widthPx = coords.size.width.toFloat(), + ), + ) + }, ) } } } -/** - * Bounds reported by each tab cell via [onGloballyPositioned]. Pixel-space so - * we can drive a `Modifier.offset { IntOffset(...) }` without re-converting - * each frame. - */ +@Composable +private fun DockTabItem( + destination: DockDestination, + 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? { + 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, ) -@Composable -private fun ExpandedDockTabs( - destinations: List, - active: BottomBarDestination, - dockHeight: Dp, - onTabSelect: (BottomBarDestination) -> Unit, +private data class ActiveIndicatorBbox( + val leftPx: Float, + val rightPx: Float, ) { - val density = LocalDensity.current - - val tabPositions = remember { mutableStateMapOf() } - - // 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(), - ) - }, - ) - } - } - } - } - } + val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f) + val centerPx: Float get() = (leftPx + rightPx) / 2f } -/** - * No-op [IndicationNodeFactory]. Provided in place of [LocalIndication.current] - * around the dock tab list so [UnstyledTab]'s `Modifier.selectable` doesn't - * paint a platform state-layer / ripple inside the cell — that would draw - * *under* the scaled-up glass pill and read as a stray tint bleeding through. - * - * The pill (size + glass tint) IS the press affordance; nothing else needed. - */ -private object NoIndication : IndicationNodeFactory { - override fun create(interactionSource: InteractionSource): DelegatableNode = object : Modifier.Node() {} - - override fun hashCode(): Int = 0 - - override fun equals(other: Any?): Boolean = other === this -} - -@Composable -private fun DockTabCell( - destination: BottomBarDestination, - isActive: Boolean, - interactionSource: MutableInteractionSource, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - // 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), - ) - } - } - } +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) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt index 9501a48..33844ee 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/CircleGlassButton.kt @@ -24,22 +24,6 @@ import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledIcon import dev.ulfrx.recipe.ui.theme.RecipeTheme -/** - * Circular Liquid-glass icon button with iOS-style press feedback. - * - * Visual behaviour on press: - * - Scale 1.0 → 1.15 (whole button briefly grows under the finger). - * - Substrate tint shifts from [RecipeTheme.colors.surfaceGlass] to a - * translucent white overlay, so the button reads "lit up". - * - * Both transitions use the same 120 ms tween with [FastOutSlowInEasing], so - * the scale and tint move together. Compose's default [androidx.compose.foundation.Indication] - * (ripple / state-layer) is suppressed (`indication = null`) — this scale + - * tint pair is the project's standard press affordance for circular chrome. - * - * Used by the dock's floating search button, the search overlay's dismiss - * button, and any future round glass action in the chrome family. - */ @Composable fun CircleGlassButton( onClick: () -> Unit, diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt index 7ec4339..6e188fc 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt @@ -3,48 +3,35 @@ package dev.ulfrx.recipe.ui.components.glass import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import io.github.fletchmckee.liquid.LiquidState +import io.github.fletchmckee.liquid.liquefiable +import io.github.fletchmckee.liquid.rememberLiquidState + +val LocalGlassBackdropState = staticCompositionLocalOf { + error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource") +} -/** - * Shared source/sampling state for glass chrome. - * - * AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends - * consume [LocalGlassBackdropState] so Liquid sample the same layer behind - * the dock/search chrome. - */ @Stable class GlassBackdropState internal constructor( - internal val liquidState: Any, + internal val liquidState: LiquidState, ) -val LocalGlassBackdropState = compositionLocalOf { null } - @Composable fun rememberGlassBackdropState(): GlassBackdropState { - val liquidState = rememberLiquidBackdropHandle() + val liquidState = rememberLiquidState() return remember(liquidState) { - GlassBackdropState( - liquidState = liquidState, - ) + GlassBackdropState(liquidState) } } @Composable -fun GlassBackdropSource( - modifier: Modifier = Modifier, - state: GlassBackdropState = rememberGlassBackdropState(), - content: @Composable BoxScope.() -> Unit, -) { - CompositionLocalProvider(LocalGlassBackdropState provides state) { - Box( - modifier = - modifier - .liquidBackdropSource(state), - content = content, - ) - } +fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { + Box( + modifier = modifier.liquefiable(state.liquidState), + content = content, + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt index 9fc07d0..560a711 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt @@ -1,23 +1,55 @@ package dev.ulfrx.recipe.ui.components.glass -import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import dev.ulfrx.recipe.ui.theme.RecipeGlassStyle import dev.ulfrx.recipe.ui.theme.RecipeTheme +import io.github.fletchmckee.liquid.liquefiable +import io.github.fletchmckee.liquid.liquid +/** + * @param recordAsSource Also register this surface as a Liquid source so other + * [GlassSurface]s sampling the same backdrop see this surface's refracted + * output — needed for nested glass-on-glass (e.g. a press overlay over the + * dock substrate). Liquid's ancestor-exclusion prevents this surface from + * sampling itself; outside its bounds it contributes nothing, so siblings + * that extend past the source's edges fall back to the shell backdrop + * seamlessly. + */ @Composable fun GlassSurface( modifier: Modifier = Modifier, tint: Color = RecipeTheme.colors.surfaceGlass, cornerRadius: Dp = 28.dp, - border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard), - edgeIntensity: Float = 0.05f, + glassStyle: RecipeGlassStyle = RecipeTheme.glass.menu, + recordAsSource: Boolean = false, content: @Composable BoxScope.() -> Unit, ) { val backdropState = LocalGlassBackdropState.current - LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, content) + val shape = RoundedCornerShape(cornerRadius) + Box( + modifier = + modifier + .clip(shape) + .then(if (recordAsSource) Modifier.liquefiable(backdropState.liquidState) else Modifier) + .liquid(backdropState.liquidState) { + refraction = glassStyle.refraction + curve = glassStyle.curve + edge = glassStyle.edge + dispersion = glassStyle.dispersion + saturation = glassStyle.saturation + contrast = glassStyle.contrast + frost = glassStyle.frost + this.shape = shape + this.tint = tint + }, + content = content, + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt index 5d70d23..eee0375 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassTextField.kt @@ -32,26 +32,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.ulfrx.recipe.ui.theme.RecipeTheme -/** - * Pill-shaped Liquid-glass text input with iOS-style press feedback. - * - * Visual behaviour on press: - * - Scale 1.0 → 1.05 (subtle — the pill is wide, so even small percentages - * are readable). Tween 120 ms `FastOutSlowInEasing`, matching the project's - * standard chrome-interaction timing. - * - **No** tint change — the keyboard appearing is its own colour event, so - * additional brightness on the field would compete. - * - * Press detection uses `Modifier.pointerInput` with `awaitEachGesture`, but - * never *consumes* the down event — the wrapped [BasicTextField] still - * receives the tap and handles focus / IME naturally. The scale animation - * runs concurrently with the focus request, so the user sees the pill bounce - * up the moment they touch it, while the keyboard slides into place. - * - * Reusable for any glass-style text input. [leadingContent] is a `null`-able - * slot for a leading icon or other affordance; if null, the field starts at - * the pill's leading edge. - */ @Composable fun GlassTextField( value: String, diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt deleted file mode 100644 index c802338..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt +++ /dev/null @@ -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) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt index 12539ff..5767ae7 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.X -import dev.ulfrx.recipe.navigation.BottomBarDestination +import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.ui.components.dock.DockBar import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton import dev.ulfrx.recipe.ui.theme.RecipeTheme @@ -53,7 +53,7 @@ fun SearchPillRow( query: String, isFocused: Boolean, placeholder: String, - activeTab: BottomBarDestination, + activeTab: DockDestination, onQueryChange: (String) -> Unit, onClose: () -> Unit, onFocusGained: () -> Unit, @@ -98,11 +98,12 @@ fun SearchPillRow( exit = sideButtonExit, ) { DockBar( - destinations = BottomBarDestination.entries, + destinations = DockDestination.entries, active = activeTab, collapsed = true, - onTabSelect = { /* unreachable while collapsed */ }, - onCollapsedTap = onClose, + // Collapsed dock only emits a re-select of the active tab, + // which here means "close the search overlay". + onTabSelect = { onClose() }, height = pillHeight, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt index 9860781..992bec1 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt @@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.ulfrx.recipe.navigation.BottomBarDestination +import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource @@ -52,7 +52,7 @@ fun PantryScreen(viewModel: PantryViewModel) { ) Box(modifier = Modifier.fillMaxSize()) { EmptyState( - icon = BottomBarDestination.Pantry.icon, + icon = DockDestination.Pantry.icon, title = stringResource(Res.string.empty_pantry_title), subtitle = stringResource(Res.string.empty_pantry_subtitle), ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt index 17fe394..bd6a246 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt @@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.ulfrx.recipe.navigation.BottomBarDestination +import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource @@ -53,7 +53,7 @@ fun RecipesScreen(viewModel: RecipesViewModel) { ) Box(modifier = Modifier.fillMaxSize()) { EmptyState( - icon = BottomBarDestination.Recipes.icon, + icon = DockDestination.Recipes.icon, title = stringResource(Res.string.empty_recipes_title), subtitle = stringResource(Res.string.empty_recipes_subtitle), ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt index b7ced4f..8bb09d9 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt @@ -1,95 +1,40 @@ package dev.ulfrx.recipe.ui.screens.shell import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.RootNavDisplay import dev.ulfrx.recipe.navigation.TabNavigator -import dev.ulfrx.recipe.ui.components.dock.DockBar -import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState -import dev.ulfrx.recipe.ui.components.search.SearchPillRow import dev.ulfrx.recipe.ui.screens.search.SearchScreen import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel import dev.ulfrx.recipe.ui.theme.RecipeTheme -import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel -import recipe.composeapp.generated.resources.Res -import recipe.composeapp.generated.resources.search_placeholder -/** - * Authenticated root composable. Owns: - * - the per-tab navigation back stacks via [TabNavigator] - * - the shell-wide search affordance via [ShellSearchViewModel] - * - * ## Body modes (driven by `searchVm.state.isOpen`) - * - * - **Closed (State A)** — `RootNavDisplay` renders the active tab; the bottom - * chrome is `[DockBar (full)] [FloatingSearchButton]`. - * - **Open (States B + C)** — [SearchScreen] takes over the body; the bottom - * chrome is [SearchPillRow], whose layout shifts further on `isFocused` - * (collapsed dock icon yields to a full-width pill + X — see SearchChrome.kt). - * - * ## Back-press handling - * - * While search is open, a [BackHandler] consumes the back press as a no-op: - * the user must exit search explicitly via the collapsed dock icon (B→A) or X - * (C→B). Confirmed product decision — no implicit dismissal while in search. - * - * ## Why TabNavigator and not the AndroidX NavController - * (Unchanged from Phase 2.1 — Nav 3 with app-owned per-tab back stacks. See - * [RootNavDisplay] for the full rationale.) - */ -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("DEPRECATION") // BackHandler → NavigationEventHandler migration deferred; the -// latter is overkill for a static "consume back" guard. Revisit when stable. @Preview @Composable fun AppShell(modifier: Modifier = Modifier) { val navigator = remember { TabNavigator() } val searchVm: ShellSearchViewModel = koinViewModel() val searchState by searchVm.state.collectAsStateWithLifecycle() - // Hoisted so both the body (liquefiable source) and the bottom chrome - // (liquid samplers) share a single LiquidState. Without this the chrome - // would fall back to a fresh, sourceless state and render as flat tint. val backdropState = rememberGlassBackdropState() - BackHandler(enabled = searchState.isOpen) { - // Blocked — user must exit search via explicit affordance (dock icon or X). - } - CompositionLocalProvider(LocalGlassBackdropState provides backdropState) { Box( modifier = @@ -97,7 +42,6 @@ fun AppShell(modifier: Modifier = Modifier) { .fillMaxSize() .background(RecipeTheme.colors.background), ) { - // Body — cross-fade between the tab stack and the search overlay. GlassBackdropSource( state = backdropState, modifier = Modifier.fillMaxSize(), @@ -122,115 +66,19 @@ fun AppShell(modifier: Modifier = Modifier) { } } - // Bottom chrome — Apple-Music-style: don't respect the full nav-bar - // inset (home indicator) for the bottom edge; halve it so chrome sits - // close to the bottom and the home indicator visually overlaps the - // chrome substrate. When IME is up, use the full IME inset (it's much - // larger than navInset/2, so `max` keeps the chrome above the keyboard). - val bottomInset = - with(LocalDensity.current) { - val imePx = WindowInsets.ime.getBottom(this) - val navPx = WindowInsets.navigationBars.getBottom(this) - maxOf(imePx, navPx / 2).toDp() - } - // Horizontal chrome padding animates with the search state: - // - Closed (dock visible) → xl (24 dp) - // - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly - // inset from the dock's footprint - // - Open, focused (search C) → 8 dp, so the input reads as a width - // extension of the keyboard above it - val horizontalPadding by animateDpAsState( - targetValue = - when { - !searchState.isOpen -> RecipeTheme.spacing.xl - !searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp - else -> 8.dp - }, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), - label = "chrome horizontal padding", + ShellBottomChrome( + activeTab = navigator.activeTab, + onTabSelect = navigator::selectTab, + search = SearchHandlers( + state = searchState, + onOpen = searchVm::open, + onQueryChange = searchVm::onQueryChange, + onClose = searchVm::close, + onFocus = searchVm::focus, + onUnfocus = searchVm::unfocus, + ), + modifier = Modifier.align(Alignment.BottomCenter), ) - 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) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt new file mode 100644 index 0000000..ceb83bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt @@ -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) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt index e6ed1cf..90f89f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt @@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.ulfrx.recipe.navigation.BottomBarDestination +import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource @@ -52,7 +52,7 @@ fun ShoppingScreen(viewModel: ShoppingViewModel) { ) Box(modifier = Modifier.fillMaxSize()) { EmptyState( - icon = BottomBarDestination.Shopping.icon, + icon = DockDestination.Shopping.icon, title = stringResource(Res.string.empty_shopping_title), subtitle = stringResource(Res.string.empty_shopping_subtitle), ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt index d6f3002..f90599e 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeColors.kt @@ -13,6 +13,7 @@ public data class RecipeColors( val content: Color, val contentMuted: Color, val accent: Color, + val chromeActive: Color, val separator: Color, val borderCard: Color, val destructive: Color, @@ -26,6 +27,7 @@ public val LightRecipeColors: RecipeColors = content = Color(0xFF0F1113), contentMuted = Color(0xFF6B6E73), accent = Color(0xFFD97757), + chromeActive = Color(0xFF0F1113).copy(alpha = 0.14f), separator = Color(0xFFE5E1DA), borderCard = Color(0xFFE5E1DA).copy(alpha = 0.60f), destructive = Color(0xFFC0392B), @@ -39,6 +41,7 @@ public val DarkRecipeColors: RecipeColors = content = Color(0xFFF1EFEA), contentMuted = Color(0xFF9AA0A6), accent = Color(0xFFE48A6E), + chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f), separator = Color(0xFF2A2D31), borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f), destructive = Color(0xFFE57368), diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt index 97b7cfc..a9017c1 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt @@ -3,26 +3,34 @@ package dev.ulfrx.recipe.ui.theme import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -/** - * Glass surface defaults (UI-SPEC § Glass / Layout). - * Consumed by GlassSurface (plan 02.1-03) and the dock / search pill / - * floating button (plan 02.1-05). - */ -public data class RecipeGlass( - val borderWidth: Dp, - val shadowOffsetY: Dp, - val shadowBlur: Dp, - val shadowAlphaLight: Float, - val shadowAlphaDark: Float, - val blurRadius: Dp, -) - -public val DefaultRecipeGlass: RecipeGlass = - RecipeGlass( - borderWidth = 1.dp, - shadowOffsetY = 8.dp, - shadowBlur = 24.dp, - shadowAlphaLight = 0.12f, - shadowAlphaDark = 0.0f, - blurRadius = 24.dp, +data object RecipeGlass { + val menu: RecipeGlassStyle = RecipeGlassStyle( + refraction = 0.10f, + curve = 0.5f, + edge = 0.05f, + dispersion = 0.05f, + saturation = 0.5f, + contrast = 1.3f, + frost = 15.dp, ) + + val dockPress: RecipeGlassStyle = RecipeGlassStyle( + refraction = 0.20f, + curve = 0.05f, + edge = 0.04f, + dispersion = 0.03f, + saturation = 0.6f, + contrast = 1.8f, + frost = 0.dp, + ) +} + +data class RecipeGlassStyle( + val refraction: Float, + val curve: Float, + val edge: Float, + val dispersion: Float, + val saturation: Float, + val contrast: Float, + val frost: Dp, +) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt index 1eb4326..ce1bbfc 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt @@ -25,9 +25,6 @@ public val LocalRecipeSpacing: ProvidableCompositionLocal = public val LocalRecipeShapes: ProvidableCompositionLocal = androidx.compose.runtime.staticCompositionLocalOf { error("RecipeShapes accessed outside RecipeTheme { }") } -public val LocalRecipeGlass: ProvidableCompositionLocal = - androidx.compose.runtime.staticCompositionLocalOf { error("RecipeGlass accessed outside RecipeTheme { }") } - @Composable public fun RecipeTheme(content: @Composable () -> Unit) { val dark = isSystemInDarkTheme() @@ -38,29 +35,27 @@ public fun RecipeTheme(content: @Composable () -> Unit) { LocalRecipeTypography provides DefaultRecipeTypography, LocalRecipeSpacing provides DefaultRecipeSpacing, LocalRecipeShapes provides DefaultRecipeShapes, - LocalRecipeGlass provides DefaultRecipeGlass, content = content, ) } -public object RecipeTheme { - public val colors: RecipeColors +object RecipeTheme { + val colors: RecipeColors @Composable @ReadOnlyComposable get() = LocalRecipeColors.current - public val typography: RecipeTypography + val typography: RecipeTypography @Composable @ReadOnlyComposable get() = LocalRecipeTypography.current - public val spacing: RecipeSpacing + val spacing: RecipeSpacing @Composable @ReadOnlyComposable get() = LocalRecipeSpacing.current - public val shapes: RecipeShapes + val shapes: RecipeShapes @Composable @ReadOnlyComposable get() = LocalRecipeShapes.current - public val glass: RecipeGlass - @Composable @ReadOnlyComposable - get() = LocalRecipeGlass.current + val glass: RecipeGlass + get() = RecipeGlass }