Restyle LiquidGlassSurface

This commit is contained in:
2026-05-15 17:49:49 +02:00
parent 35eea8cfc8
commit 48b41fd4af
8 changed files with 531 additions and 170 deletions

View File

@@ -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,6 +230,9 @@ private fun ExpandedDockTabs(
LaunchedEffect(pillTargetTab) {
if (!initialized) return@LaunchedEffect
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
isXWAnimating = true
try {
coroutineScope {
launch {
pillX.animateTo(
targetValue = t.offsetXPx - pillExpansionPx,
@@ -213,6 +246,10 @@ private fun ExpandedDockTabs(
)
}
}
} finally {
isXWAnimating = false
}
}
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
// FastOutSlowInEasing so all chrome interactions read uniformly.
@@ -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(
LaunchedEffect(isPressActive) {
isScaleAnimating = true
try {
pillScale.animateTo(
targetValue = if (isPressActive) 1.35f else 1f,
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
label = "Dock pill scale",
)
val pillTint = Color.White.copy(alpha = 0.18f)
} 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
@@ -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(

View File

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

View File

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

View File

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

View File

@@ -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<GlassTestItem> =
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)),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Planner.icon,
title = stringResource(Res.string.empty_planner_title),
subtitle = stringResource(Res.string.empty_planner_subtitle),
// 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
.size(20.dp)
.padding(end = 0.dp),
)
}
}
}
}

View File

@@ -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,12 +57,38 @@ 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),
) {
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
@@ -54,13 +96,6 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
.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 {
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<SearchResultSample> =
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)),
)
}
}
}
}

View File

@@ -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,11 +81,16 @@ 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 =
modifier
@@ -90,7 +98,10 @@ fun AppShell(modifier: Modifier = Modifier) {
.background(RecipeTheme.colors.background),
) {
// Body — cross-fade between the tab stack and the search overlay.
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
GlassBackdropSource(
state = backdropState,
modifier = Modifier.fillMaxSize(),
) {
AnimatedContent(
targetState = searchState.isOpen,
modifier = Modifier.fillMaxSize(),
@@ -196,6 +207,7 @@ fun AppShell(modifier: Modifier = Modifier) {
}
}
}
}
@Composable
private fun DefaultDockRow(

View File

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