From 48b41fd4afab12910bf79f52a3dba1aba714c3bf Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Fri, 15 May 2026 17:49:49 +0200 Subject: [PATCH] Restyle LiquidGlassSurface --- .../recipe/ui/components/dock/DockBar.kt | 120 ++++++++-- .../ui/components/glass/GlassSurface.kt | 3 +- .../ui/components/glass/GlassTextField.kt | 3 +- .../ui/components/glass/LiquidGlassSurface.kt | 12 +- .../ui/screens/planner/PlannerScreen.kt | 170 ++++++++++++-- .../recipe/ui/screens/search/SearchScreen.kt | 169 ++++++++++++-- .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 220 +++++++++--------- .../dev/ulfrx/recipe/ui/theme/RecipeColors.kt | 4 +- 8 files changed, 531 insertions(+), 170 deletions(-) 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 65894aa..63ef519 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,11 +1,14 @@ 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 @@ -52,6 +55,7 @@ import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res @@ -111,9 +115,14 @@ fun DockBar( // 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. @@ -125,6 +134,19 @@ fun DockBar( 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), + ), + ) } } } @@ -181,7 +203,15 @@ private fun ExpandedDockTabs( 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]) { @@ -200,17 +230,24 @@ private fun ExpandedDockTabs( LaunchedEffect(pillTargetTab) { if (!initialized) return@LaunchedEffect val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect - 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), - ) + 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 } } @@ -225,18 +262,50 @@ private fun ExpandedDockTabs( // - Same uniform factor on width preserves the rest pill's shape (a // full capsule, cornerRadius = height/2 scales with the rest of the // rect, so the scaled pill is *the same shape, just bigger*). - // - // Tint is **not** animated: the pill is always glass — the same - // `Color.White @ 0.18` overlay [CircleGlassButton] uses on press — - // regardless of state. Active vs inactive is read from the icon + label - // colour (accent vs muted), not from a contrasting fill behind them. val isPressActive = pressedTab != null - val pillScale by animateFloatAsState( - targetValue = if (isPressActive) 1.35f else 1f, + 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 scale", + 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", ) - val pillTint = Color.White.copy(alpha = 0.18f) // Pill's resting visual height after the 4 dp inset on all sides. val pillCorner = (dockHeight - 8.dp) / 2 @@ -263,10 +332,11 @@ private fun ExpandedDockTabs( .width(with(density) { pillW.value.toDp() }) .fillMaxHeight() .padding(4.dp) - .scale(pillScale), + .scale(pillScale.value), cornerRadius = pillCorner, tint = pillTint, - border = null, + border = BorderStroke(1.dp, pillBorderColor), + edgeIntensity = pillEdge, ) {} } @@ -343,7 +413,11 @@ private fun DockTabCell( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted + // 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( 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 17714f5..9fc07d0 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 @@ -15,8 +15,9 @@ fun GlassSurface( tint: Color = RecipeTheme.colors.surfaceGlass, cornerRadius: Dp = 28.dp, border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard), + edgeIntensity: Float = 0.05f, content: @Composable BoxScope.() -> Unit, ) { val backdropState = LocalGlassBackdropState.current - LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content) + LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, edgeIntensity, 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 47e9acf..5d70d23 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp @@ -123,7 +124,7 @@ fun GlassTextField( text = placeholder, style = RecipeTheme.typography.body.copy( - color = RecipeTheme.colors.contentMuted, + color = Color.White, ), ) } 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 index ac8da9c..c802338 100644 --- 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 @@ -1,7 +1,6 @@ package dev.ulfrx.recipe.ui.components.glass import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -29,6 +28,7 @@ internal fun LiquidGlassSurface( cornerRadius: Dp, border: BorderStroke?, backdropState: GlassBackdropState?, + edgeIntensity: Float, content: @Composable BoxScope.() -> Unit, ) { val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState() @@ -38,10 +38,16 @@ internal fun LiquidGlassSurface( modifier .clip(shape) .liquid(state) { - frost = 24.dp + 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 - }.background(tint, shape) + } .let { if (border != null) it.border(border, shape) else it }, content = content, ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt index 3f8e80a..dad70a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt @@ -4,23 +4,31 @@ import androidx.compose.foundation.background 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.Spacer 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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.lifecycle.compose.collectAsStateWithLifecycle -import dev.ulfrx.recipe.navigation.BottomBarDestination -import dev.ulfrx.recipe.ui.components.empty.EmptyState import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res -import recipe.composeapp.generated.resources.empty_planner_subtitle -import recipe.composeapp.generated.resources.empty_planner_title import recipe.composeapp.generated.resources.shell_tab_planner /** @@ -34,32 +42,156 @@ fun PlannerScreen(viewModel: PlannerViewModel) { @Suppress("UNUSED_VARIABLE") val state by viewModel.state.collectAsStateWithLifecycle() + val bgDark = Color(0xFF14181F) + val titleColor = Color(0xFFE8E4DC) + Box( modifier = Modifier .fillMaxSize() - .background(RecipeTheme.colors.background), + .background(bgDark), ) { + // Scrollable, visually rich content sitting behind the glass chrome. + // Bottom contentPadding extends well past the dock so items keep + // scrolling under it (the whole point of this test view). + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars), + contentPadding = + PaddingValues( + start = RecipeTheme.spacing.lg, + end = RecipeTheme.spacing.lg, + top = RecipeTheme.spacing.xl + 48.dp, + bottom = 160.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = GlassTestItems, key = { it.id }) { item -> + GlassTestCard(item = item) + } + } + + // Title pinned at the top so the chrome glass doesn't have to refract + // over the very top of the scrollable list. + BasicText( + text = stringResource(Res.string.shell_tab_planner), + style = RecipeTheme.typography.title.copy(color = titleColor), + modifier = + Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding( + top = RecipeTheme.spacing.xl, + start = RecipeTheme.spacing.lg, + ), + ) + } +} + +private data class GlassTestItem( + val id: Int, + val accent: Color, + val cardTone: Color, + val titleWeight: Float, + val subtitleWeight: Float, +) + +private val GlassTestItems: List = + run { + val accents = + listOf( + Color(0xFFD97757), // accent terracotta + Color(0xFF6EA987), // sage + Color(0xFF7A8FB8), // dusty blue + Color(0xFFC1864F), // amber + Color(0xFFB76E79), // muted rose + Color(0xFF6B7A8F), // slate + Color(0xFF8E7CC3), // muted violet + ) + val tones = + listOf( + Color(0xFF1F242C), + Color(0xFF232932), + Color(0xFF1B2028), + Color(0xFF272D36), + ) + List(40) { i -> + GlassTestItem( + id = i, + accent = accents[i % accents.size], + cardTone = tones[i % tones.size], + titleWeight = 0.80f + ((i * 13) % 20) / 100f, + subtitleWeight = 0.55f + ((i * 7) % 40) / 100f, + ) + } + } + +@Composable +private fun GlassTestCard(item: GlassTestItem) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(88.dp) + .clip(RoundedCornerShape(16.dp)) + .background(item.cardTone), + ) { + // Left accent stripe — varied saturated colors so the dock chrome + // gets to refract a clear hue band as you scroll past. + Box( + modifier = + Modifier + .width(6.dp) + .fillMaxSize() + .background(item.accent), + ) Column( modifier = Modifier .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = RecipeTheme.spacing.xl), - verticalArrangement = Arrangement.Top, + .padding( + start = 12.dp + 6.dp, + end = 12.dp, + top = 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), ) { - BasicText( - text = stringResource(Res.string.shell_tab_planner), - style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content), - modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg), + // Title bar + Box( + modifier = + Modifier + .fillMaxWidth(item.titleWeight) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFFE8E4DC).copy(alpha = 0.85f)), + ) + // Subtitle bar + Box( + modifier = + Modifier + .fillMaxWidth(item.subtitleWeight) + .height(10.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFFE8E4DC).copy(alpha = 0.40f)), + ) + Spacer(modifier = Modifier.height(2.dp)) + // Faint metadata dot + bar + Box( + modifier = + Modifier + .fillMaxWidth(0.18f) + .height(8.dp) + .clip(RoundedCornerShape(2.dp)) + .background(item.accent.copy(alpha = 0.55f)), ) - Box(modifier = Modifier.fillMaxSize()) { - EmptyState( - icon = BottomBarDestination.Planner.icon, - title = stringResource(Res.string.empty_planner_title), - subtitle = stringResource(Res.string.empty_planner_subtitle), - ) - } } + Box( + modifier = + Modifier + .size(20.dp) + .padding(end = 0.dp), + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt index d369ccf..5f667dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt @@ -1,15 +1,33 @@ package dev.ulfrx.recipe.ui.screens.search import androidx.compose.foundation.background +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment 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.lifecycle.compose.collectAsStateWithLifecycle import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Search @@ -19,8 +37,6 @@ import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.search_screen_curated_subtitle import recipe.composeapp.generated.resources.search_screen_curated_title -import recipe.composeapp.generated.resources.search_screen_empty_results_subtitle -import recipe.composeapp.generated.resources.search_screen_empty_results_title /** * Global search destination — overlays the active tab when @@ -41,26 +57,45 @@ import recipe.composeapp.generated.resources.search_screen_empty_results_title fun SearchScreen(viewModel: ShellSearchViewModel) { val state by viewModel.state.collectAsStateWithLifecycle() + val bgDark = Color(0xFF14181F) + Box( modifier = Modifier .fillMaxSize() - .background(RecipeTheme.colors.background), + .background(if (state.isFocused) bgDark else RecipeTheme.colors.background), ) { - Box( - modifier = - Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = RecipeTheme.spacing.xl), - ) { - if (state.isFocused) { - EmptyState( - icon = Lucide.Search, - title = stringResource(Res.string.search_screen_empty_results_title), - subtitle = stringResource(Res.string.search_screen_empty_results_subtitle), - ) - } else { + if (state.isFocused) { + // Sample search-result list — visual aid so the search pill / dock + // chrome has scrollable content underneath while wiring up real + // SearchSources lands in later phases. Remove once Phase 5/6/8/9 + // back this screen with real results. + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars), + contentPadding = + PaddingValues( + start = RecipeTheme.spacing.lg, + end = RecipeTheme.spacing.lg, + top = RecipeTheme.spacing.xl, + bottom = 160.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(items = SearchResultSamples, key = { it.id }) { item -> + SearchResultRow(item = item) + } + } + } else { + Box( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = RecipeTheme.spacing.xl), + ) { EmptyState( icon = Lucide.Search, title = stringResource(Res.string.search_screen_curated_title), @@ -70,3 +105,103 @@ fun SearchScreen(viewModel: ShellSearchViewModel) { } } } + +private data class SearchResultSample( + val id: Int, + val avatarColor: Color, + val cardTone: Color, + val titleWeight: Float, + val subtitleWeight: Float, + val tagWeight: Float, +) + +private val SearchResultSamples: List = + run { + val avatars = + listOf( + Color(0xFFD97757), // terracotta + Color(0xFF6EA987), // sage + Color(0xFF7A8FB8), // dusty blue + Color(0xFFC1864F), // amber + Color(0xFFB76E79), // muted rose + Color(0xFF6B7A8F), // slate + Color(0xFF8E7CC3), // muted violet + Color(0xFFA89B7C), // olive + ) + val tones = + listOf( + Color(0xFF1F242C), + Color(0xFF232932), + Color(0xFF1B2028), + Color(0xFF272D36), + ) + List(36) { i -> + SearchResultSample( + id = i, + avatarColor = avatars[i % avatars.size], + cardTone = tones[i % tones.size], + titleWeight = 0.62f + ((i * 11) % 30) / 100f, + subtitleWeight = 0.40f + ((i * 7) % 35) / 100f, + tagWeight = 0.12f + ((i * 5) % 14) / 100f, + ) + } + } + +@Composable +private fun SearchResultRow(item: SearchResultSample) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(76.dp) + .clip(RoundedCornerShape(14.dp)) + .background(item.cardTone) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Round avatar / thumbnail slot — gives each row a recognizable + // colored anchor that refracts cleanly through the search pill above. + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background(item.avatarColor), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.fillMaxHeight().padding(vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + ) { + // Title bar + Box( + modifier = + Modifier + .fillMaxWidth(item.titleWeight) + .height(13.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFFE8E4DC).copy(alpha = 0.85f)), + ) + // Subtitle bar + Box( + modifier = + Modifier + .fillMaxWidth(item.subtitleWeight) + .height(9.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color(0xFFE8E4DC).copy(alpha = 0.40f)), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + // Small accent tag pill + Box( + modifier = + Modifier + .fillMaxWidth(item.tagWeight) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(item.avatarColor.copy(alpha = 0.65f)), + ) + } + } + } +} 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 934831a..b7ced4f 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 @@ -21,6 +21,7 @@ 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 @@ -37,6 +38,8 @@ 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 @@ -78,119 +81,128 @@ 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). } - Box( - modifier = - modifier - .fillMaxSize() - .background(RecipeTheme.colors.background), - ) { - // Body — cross-fade between the tab stack and the search overlay. - GlassBackdropSource(modifier = Modifier.fillMaxSize()) { - AnimatedContent( - targetState = searchState.isOpen, + CompositionLocalProvider(LocalGlassBackdropState provides backdropState) { + Box( + modifier = + modifier + .fillMaxSize() + .background(RecipeTheme.colors.background), + ) { + // Body — cross-fade between the tab stack and the search overlay. + GlassBackdropSource( + state = backdropState, modifier = Modifier.fillMaxSize(), - transitionSpec = { - fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith - fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing)) - }, - label = "AppShell body", - ) { searchOpen -> - if (searchOpen) { - SearchScreen(viewModel = searchVm) - } else { - RootNavDisplay( - navigator = navigator, - modifier = Modifier.fillMaxSize(), - ) + ) { + AnimatedContent( + targetState = searchState.isOpen, + modifier = Modifier.fillMaxSize(), + transitionSpec = { + fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(durationMillis = 200, easing = FastOutSlowInEasing)) + }, + label = "AppShell body", + ) { searchOpen -> + if (searchOpen) { + SearchScreen(viewModel = searchVm) + } else { + RootNavDisplay( + navigator = navigator, + modifier = Modifier.fillMaxSize(), + ) + } } } - } - // Bottom chrome — Apple-Music-style: don't respect the full nav-bar - // inset (home indicator) for the bottom edge; halve it so chrome sits - // close to the bottom and the home indicator visually overlaps the - // chrome substrate. When IME is up, use the full IME inset (it's much - // larger than navInset/2, so `max` keeps the chrome above the keyboard). - val bottomInset = - with(LocalDensity.current) { - val imePx = WindowInsets.ime.getBottom(this) - val navPx = WindowInsets.navigationBars.getBottom(this) - maxOf(imePx, navPx / 2).toDp() - } - // Horizontal chrome padding animates with the search state: - // - Closed (dock visible) → xl (24 dp) - // - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly - // inset from the dock's footprint - // - Open, focused (search C) → 8 dp, so the input reads as a width - // extension of the keyboard above it - val horizontalPadding by animateDpAsState( - targetValue = - when { - !searchState.isOpen -> RecipeTheme.spacing.xl - !searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp - else -> 8.dp - }, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), - label = "chrome horizontal padding", - ) - Row( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding( - start = horizontalPadding, - end = horizontalPadding, - top = RecipeTheme.spacing.sm, - bottom = bottomInset + RecipeTheme.spacing.xs, - ), - horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - AnimatedContent( - targetState = searchState.isOpen, - // Lock chrome region to the dock's height in both modes so - // (a) the body above doesn't shift when search opens / closes, - // and (b) the (shorter) search pill is centred vertically - // inside the same band the dock occupies. - modifier = Modifier.fillMaxWidth().height(63.dp), - contentAlignment = Alignment.Center, - // Exit is instant (no fade-out): the outgoing chrome cell — - // dock OR search pill row — may still be playing its press - // animation (the user's finger triggered the tap that switched - // states). If we also fade it out, the half-faded pressed-up - // button overlaps visually with the incoming pill, which reads - // as "two things on screen at once". Instant exit makes the - // hand-off feel clean while the press animation keeps running - // off-screen on the now-removed branch. - transitionSpec = { - fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith - ExitTransition.None - }, - label = "AppShell bottom chrome", - ) { searchOpen -> - if (searchOpen) { - SearchPillRow( - query = searchState.query, - isFocused = searchState.isFocused, - placeholder = stringResource(Res.string.search_placeholder), - activeTab = navigator.activeTab, - onQueryChange = searchVm::onQueryChange, - onClose = searchVm::close, - onFocusGained = searchVm::focus, - onFocusLost = searchVm::unfocus, - ) - } else { - DefaultDockRow( - activeTab = navigator.activeTab, - onTabSelect = navigator::selectTab, - onSearchTap = searchVm::open, - ) + // Bottom chrome — Apple-Music-style: don't respect the full nav-bar + // inset (home indicator) for the bottom edge; halve it so chrome sits + // close to the bottom and the home indicator visually overlaps the + // chrome substrate. When IME is up, use the full IME inset (it's much + // larger than navInset/2, so `max` keeps the chrome above the keyboard). + val bottomInset = + with(LocalDensity.current) { + val imePx = WindowInsets.ime.getBottom(this) + val navPx = WindowInsets.navigationBars.getBottom(this) + maxOf(imePx, navPx / 2).toDp() + } + // Horizontal chrome padding animates with the search state: + // - Closed (dock visible) → xl (24 dp) + // - Open, unfocused (search B) → xl + 2 dp, so the pill sits slightly + // inset from the dock's footprint + // - Open, focused (search C) → 8 dp, so the input reads as a width + // extension of the keyboard above it + val horizontalPadding by animateDpAsState( + targetValue = + when { + !searchState.isOpen -> RecipeTheme.spacing.xl + !searchState.isFocused -> RecipeTheme.spacing.xl + 2.dp + else -> 8.dp + }, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "chrome horizontal padding", + ) + Row( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = RecipeTheme.spacing.sm, + bottom = bottomInset + RecipeTheme.spacing.xs, + ), + horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = searchState.isOpen, + // Lock chrome region to the dock's height in both modes so + // (a) the body above doesn't shift when search opens / closes, + // and (b) the (shorter) search pill is centred vertically + // inside the same band the dock occupies. + modifier = Modifier.fillMaxWidth().height(63.dp), + contentAlignment = Alignment.Center, + // Exit is instant (no fade-out): the outgoing chrome cell — + // dock OR search pill row — may still be playing its press + // animation (the user's finger triggered the tap that switched + // states). If we also fade it out, the half-faded pressed-up + // button overlaps visually with the incoming pill, which reads + // as "two things on screen at once". Instant exit makes the + // hand-off feel clean while the press animation keeps running + // off-screen on the now-removed branch. + transitionSpec = { + fadeIn(tween(durationMillis = 200, easing = FastOutSlowInEasing)) togetherWith + ExitTransition.None + }, + label = "AppShell bottom chrome", + ) { searchOpen -> + if (searchOpen) { + SearchPillRow( + query = searchState.query, + isFocused = searchState.isFocused, + placeholder = stringResource(Res.string.search_placeholder), + activeTab = navigator.activeTab, + onQueryChange = searchVm::onQueryChange, + onClose = searchVm::close, + onFocusGained = searchVm::focus, + onFocusLost = searchVm::unfocus, + ) + } else { + DefaultDockRow( + activeTab = navigator.activeTab, + onTabSelect = navigator::selectTab, + onSearchTap = searchVm::open, + ) + } } } } 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 c888e4a..d6f3002 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 @@ -22,7 +22,7 @@ public val LightRecipeColors: RecipeColors = RecipeColors( background = Color(0xFFF7F5F1), surface = Color(0xFFFFFFFF), - surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.60f), + surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f), content = Color(0xFF0F1113), contentMuted = Color(0xFF6B6E73), accent = Color(0xFFD97757), @@ -35,7 +35,7 @@ public val DarkRecipeColors: RecipeColors = RecipeColors( background = Color(0xFF0F1113), surface = Color(0xFF1A1D21), - surfaceGlass = Color(0xFF1A1D21).copy(alpha = 0.55f), + surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.18f), content = Color(0xFFF1EFEA), contentMuted = Color(0xFF9AA0A6), accent = Color(0xFFE48A6E),