Restyle LiquidGlassSurface
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user