Restyle LiquidGlassSurface
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.dock
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.IndicationNodeFactory
|
import androidx.compose.foundation.IndicationNodeFactory
|
||||||
import androidx.compose.foundation.LocalIndication
|
import androidx.compose.foundation.LocalIndication
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.interaction.InteractionSource
|
import androidx.compose.foundation.interaction.InteractionSource
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
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.CircleGlassButton
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -111,9 +115,14 @@ fun DockBar(
|
|||||||
// Outer Box — no clip — hosts the dock substrate AND the tabs+pill
|
// 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.
|
// layer so the pressed pill can scale (1.20×) past the dock contours.
|
||||||
Box(modifier = modifier.height(height)) {
|
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(
|
GlassSurface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
cornerRadius = height / 2,
|
cornerRadius = height / 2,
|
||||||
|
border = null,
|
||||||
) {
|
) {
|
||||||
// Empty: the actual pill + tabs live in the sibling overlay
|
// Empty: the actual pill + tabs live in the sibling overlay
|
||||||
// below, outside this GlassSurface's content clip.
|
// below, outside this GlassSurface's content clip.
|
||||||
@@ -125,6 +134,19 @@ fun DockBar(
|
|||||||
dockHeight = height,
|
dockHeight = height,
|
||||||
onTabSelect = onTabSelect,
|
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 pillX = remember { Animatable(0f) }
|
||||||
val pillW = remember { Animatable(0f) }
|
val pillW = remember { Animatable(0f) }
|
||||||
|
val pillScale = remember { Animatable(1f) }
|
||||||
var initialized by remember { mutableStateOf(false) }
|
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.
|
// First measurement: snap pill to the active cell so cold paint is correct.
|
||||||
LaunchedEffect(tabPositions[pillTargetTab]) {
|
LaunchedEffect(tabPositions[pillTargetTab]) {
|
||||||
@@ -200,6 +230,9 @@ private fun ExpandedDockTabs(
|
|||||||
LaunchedEffect(pillTargetTab) {
|
LaunchedEffect(pillTargetTab) {
|
||||||
if (!initialized) return@LaunchedEffect
|
if (!initialized) return@LaunchedEffect
|
||||||
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
val t = tabPositions[pillTargetTab] ?: return@LaunchedEffect
|
||||||
|
isXWAnimating = true
|
||||||
|
try {
|
||||||
|
coroutineScope {
|
||||||
launch {
|
launch {
|
||||||
pillX.animateTo(
|
pillX.animateTo(
|
||||||
targetValue = t.offsetXPx - pillExpansionPx,
|
targetValue = t.offsetXPx - pillExpansionPx,
|
||||||
@@ -213,6 +246,10 @@ private fun ExpandedDockTabs(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
isXWAnimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
|
// Press-feedback animation — matches [CircleGlassButton]'s 120 ms /
|
||||||
// FastOutSlowInEasing so all chrome interactions read uniformly.
|
// 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
|
// - Same uniform factor on width preserves the rest pill's shape (a
|
||||||
// full capsule, cornerRadius = height/2 scales with the rest of the
|
// full capsule, cornerRadius = height/2 scales with the rest of the
|
||||||
// rect, so the scaled pill is *the same shape, just bigger*).
|
// 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 isPressActive = pressedTab != null
|
||||||
val pillScale by animateFloatAsState(
|
LaunchedEffect(isPressActive) {
|
||||||
|
isScaleAnimating = true
|
||||||
|
try {
|
||||||
|
pillScale.animateTo(
|
||||||
targetValue = if (isPressActive) 1.35f else 1f,
|
targetValue = if (isPressActive) 1.35f else 1f,
|
||||||
animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing),
|
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.
|
// Pill's resting visual height after the 4 dp inset on all sides.
|
||||||
val pillCorner = (dockHeight - 8.dp) / 2
|
val pillCorner = (dockHeight - 8.dp) / 2
|
||||||
@@ -263,10 +332,11 @@ private fun ExpandedDockTabs(
|
|||||||
.width(with(density) { pillW.value.toDp() })
|
.width(with(density) { pillW.value.toDp() })
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
.scale(pillScale),
|
.scale(pillScale.value),
|
||||||
cornerRadius = pillCorner,
|
cornerRadius = pillCorner,
|
||||||
tint = pillTint,
|
tint = pillTint,
|
||||||
border = null,
|
border = BorderStroke(1.dp, pillBorderColor),
|
||||||
|
edgeIntensity = pillEdge,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +413,11 @@ private fun DockTabCell(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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 labelText = stringResource(destination.labelRes)
|
||||||
val a11ySuffix = if (isActive) ", aktywna" else ""
|
val a11ySuffix = if (isActive) ", aktywna" else ""
|
||||||
UnstyledTab(
|
UnstyledTab(
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ fun GlassSurface(
|
|||||||
tint: Color = RecipeTheme.colors.surfaceGlass,
|
tint: Color = RecipeTheme.colors.surfaceGlass,
|
||||||
cornerRadius: Dp = 28.dp,
|
cornerRadius: Dp = 28.dp,
|
||||||
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
||||||
|
edgeIntensity: Float = 0.05f,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val backdropState = LocalGlassBackdropState.current
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -123,7 +124,7 @@ fun GlassTextField(
|
|||||||
text = placeholder,
|
text = placeholder,
|
||||||
style =
|
style =
|
||||||
RecipeTheme.typography.body.copy(
|
RecipeTheme.typography.body.copy(
|
||||||
color = RecipeTheme.colors.contentMuted,
|
color = Color.White,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.glass
|
package dev.ulfrx.recipe.ui.components.glass
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
@@ -29,6 +28,7 @@ internal fun LiquidGlassSurface(
|
|||||||
cornerRadius: Dp,
|
cornerRadius: Dp,
|
||||||
border: BorderStroke?,
|
border: BorderStroke?,
|
||||||
backdropState: GlassBackdropState?,
|
backdropState: GlassBackdropState?,
|
||||||
|
edgeIntensity: Float,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
|
val state = backdropState?.liquidState as? LiquidState ?: rememberLiquidState()
|
||||||
@@ -38,10 +38,16 @@ internal fun LiquidGlassSurface(
|
|||||||
modifier
|
modifier
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.liquid(state) {
|
.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.shape = shape
|
||||||
this.tint = tint
|
this.tint = tint
|
||||||
}.background(tint, shape)
|
}
|
||||||
.let { if (border != null) it.border(border, shape) else it },
|
.let { if (border != null) it.border(border, shape) else it },
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,23 +4,31 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.BottomBarDestination
|
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
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
|
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,32 +42,156 @@ fun PlannerScreen(viewModel: PlannerViewModel) {
|
|||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val bgDark = Color(0xFF14181F)
|
||||||
|
val titleColor = Color(0xFFE8E4DC)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
.padding(
|
||||||
.padding(top = RecipeTheme.spacing.xl),
|
start = 12.dp + 6.dp,
|
||||||
verticalArrangement = Arrangement.Top,
|
end = 12.dp,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 12.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
) {
|
) {
|
||||||
BasicText(
|
// Title bar
|
||||||
text = stringResource(Res.string.shell_tab_planner),
|
Box(
|
||||||
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
|
modifier =
|
||||||
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
|
Modifier
|
||||||
|
.fillMaxWidth(item.titleWeight)
|
||||||
|
.height(14.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// Subtitle bar
|
||||||
EmptyState(
|
Box(
|
||||||
icon = BottomBarDestination.Planner.icon,
|
modifier =
|
||||||
title = stringResource(Res.string.empty_planner_title),
|
Modifier
|
||||||
subtitle = stringResource(Res.string.empty_planner_subtitle),
|
.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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.search
|
package dev.ulfrx.recipe.ui.screens.search
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.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.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
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.Res
|
||||||
import recipe.composeapp.generated.resources.search_screen_curated_subtitle
|
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_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
|
* 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) {
|
fun SearchScreen(viewModel: ShellSearchViewModel) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val bgDark = Color(0xFF14181F)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -54,13 +96,6 @@ fun SearchScreen(viewModel: ShellSearchViewModel) {
|
|||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
.padding(top = RecipeTheme.spacing.xl),
|
.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(
|
EmptyState(
|
||||||
icon = Lucide.Search,
|
icon = Lucide.Search,
|
||||||
title = stringResource(Res.string.search_screen_curated_title),
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -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.DockBar
|
||||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
||||||
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
@@ -78,11 +81,16 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
val navigator = remember { TabNavigator() }
|
val navigator = remember { TabNavigator() }
|
||||||
val searchVm: ShellSearchViewModel = koinViewModel()
|
val searchVm: ShellSearchViewModel = koinViewModel()
|
||||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
||||||
|
// Hoisted so both the body (liquefiable source) and the bottom chrome
|
||||||
|
// (liquid samplers) share a single LiquidState. Without this the chrome
|
||||||
|
// would fall back to a fresh, sourceless state and render as flat tint.
|
||||||
|
val backdropState = rememberGlassBackdropState()
|
||||||
|
|
||||||
BackHandler(enabled = searchState.isOpen) {
|
BackHandler(enabled = searchState.isOpen) {
|
||||||
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
// Blocked — user must exit search via explicit affordance (dock icon or X).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -90,7 +98,10 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
.background(RecipeTheme.colors.background),
|
.background(RecipeTheme.colors.background),
|
||||||
) {
|
) {
|
||||||
// Body — cross-fade between the tab stack and the search overlay.
|
// Body — cross-fade between the tab stack and the search overlay.
|
||||||
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
|
GlassBackdropSource(
|
||||||
|
state = backdropState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = searchState.isOpen,
|
targetState = searchState.isOpen,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -196,6 +207,7 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultDockRow(
|
private fun DefaultDockRow(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public val LightRecipeColors: RecipeColors =
|
|||||||
RecipeColors(
|
RecipeColors(
|
||||||
background = Color(0xFFF7F5F1),
|
background = Color(0xFFF7F5F1),
|
||||||
surface = Color(0xFFFFFFFF),
|
surface = Color(0xFFFFFFFF),
|
||||||
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.60f),
|
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.42f),
|
||||||
content = Color(0xFF0F1113),
|
content = Color(0xFF0F1113),
|
||||||
contentMuted = Color(0xFF6B6E73),
|
contentMuted = Color(0xFF6B6E73),
|
||||||
accent = Color(0xFFD97757),
|
accent = Color(0xFFD97757),
|
||||||
@@ -35,7 +35,7 @@ public val DarkRecipeColors: RecipeColors =
|
|||||||
RecipeColors(
|
RecipeColors(
|
||||||
background = Color(0xFF0F1113),
|
background = Color(0xFF0F1113),
|
||||||
surface = Color(0xFF1A1D21),
|
surface = Color(0xFF1A1D21),
|
||||||
surfaceGlass = Color(0xFF1A1D21).copy(alpha = 0.55f),
|
surfaceGlass = Color(0xFFFFFFFF).copy(alpha = 0.18f),
|
||||||
content = Color(0xFFF1EFEA),
|
content = Color(0xFFF1EFEA),
|
||||||
contentMuted = Color(0xFF9AA0A6),
|
contentMuted = Color(0xFF9AA0A6),
|
||||||
accent = Color(0xFFE48A6E),
|
accent = Color(0xFFE48A6E),
|
||||||
|
|||||||
Reference in New Issue
Block a user