Compare commits

..

2 Commits

Author SHA1 Message Date
22b43050d6 Implement calendar pill widgets 2026-05-28 23:12:53 +02:00
579504b927 Change dark theme colors 2026-05-26 22:54:13 +02:00
28 changed files with 1032 additions and 224 deletions

View File

@@ -68,4 +68,12 @@
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
<!-- Bottom calendar pill (planer / spiżarnia / zakupy) -->
<string name="calendar_horizon_today">Tylko dziś</string>
<string name="calendar_horizon_days">Najbliższe %1$d dni</string>
<!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) -->
<string name="pantry_shortfall_count">%1$d braków</string>
<string name="shopping_buy_count">%1$d do kupienia</string>
</resources>

View File

@@ -3,9 +3,9 @@ package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel

View File

@@ -2,12 +2,13 @@ package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -15,20 +16,17 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composeunstyled.UnstyledButton
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
/**
* Single day cell — circle, optional outline ring for "today", optional fill
* for "selected", optional dot indicator below the date number. Disabled days
* render as a non-interactive box.
*/
@Composable
internal fun CalendarDayCell(
date: LocalDate,
@@ -37,62 +35,71 @@ internal fun CalendarDayCell(
isToday: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
numberStyle: TextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light),
cellHeight: Dp = 36.dp,
header: String? = null,
headerStyle: TextStyle =
RecipeTheme.typography.label.copy(
fontWeight = FontWeight.Light,
fontSize = 9.sp,
lineHeight = 10.sp,
),
) {
val colors = RecipeTheme.colors
val baseColor = colors.content
val mutedColor = colors.contentMuted
val accent = colors.accent
val background: Color =
when {
isSelected -> accent.copy(alpha = 0.18f)
else -> Color.Transparent
}
val textColor: Color =
val background = if (isSelected) accent.copy(alpha = 0.18f) else Color.Transparent
val textColor =
when {
state.disabled -> mutedColor.copy(alpha = 0.45f)
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
isSelected -> accent
else -> baseColor
}
val ringColor: Color =
val headerColor =
if (isSelected || state.dimmed || state.disabled) textColor else mutedColor
val ringColor =
when {
isSelected -> accent.copy(alpha = 0.55f)
isToday -> baseColor.copy(alpha = 0.35f)
else -> Color.Transparent
}
val indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = INDICATOR_MUTED_ALPHA)
val cellModifier =
modifier
.height(36.dp)
.fillMaxWidth()
val cellModifier = modifier.height(cellHeight).fillMaxWidth()
val isClickable = LocalCalendarInteractive.current && !state.disabled
if (state.disabled) {
Box(modifier = cellModifier, contentAlignment = Alignment.Center) {
DayCellInner(
date = date,
textColor = textColor,
indicator = state.indicator,
indicatorColor = mutedColor.copy(alpha = 0.5f),
)
}
return
}
UnstyledButton(
onClick = onClick,
backgroundColor = background,
contentColor = textColor,
shape = CircleShape,
borderColor = ringColor,
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
modifier = cellModifier,
) {
val content: @Composable () -> Unit = {
DayCellInner(
date = date,
textColor = textColor,
numberStyle = numberStyle,
header = header,
headerStyle = headerStyle,
headerColor = headerColor,
indicator = state.indicator,
indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = 0.65f),
indicatorColor = indicatorColor,
)
}
if (isClickable) {
UnstyledButton(
onClick = onClick,
backgroundColor = background,
contentColor = textColor,
shape = CircleShape,
borderColor = ringColor,
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
modifier = cellModifier,
content = { content() },
)
} else {
Box(
modifier = cellModifier.dayCellSurface(background, ringColor),
contentAlignment = Alignment.Center,
content = { content() },
)
}
}
@@ -101,31 +108,120 @@ internal fun CalendarDayCell(
private fun DayCellInner(
date: LocalDate,
textColor: Color,
numberStyle: TextStyle,
header: String?,
headerStyle: TextStyle,
headerColor: Color,
indicator: Boolean,
indicatorColor: Color,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (header == null) {
CenteredDayNumber(
date = date,
textColor = textColor,
numberStyle = numberStyle,
indicator = indicator,
indicatorColor = indicatorColor,
)
} else {
HeaderDayNumber(
date = date,
textColor = textColor,
numberStyle = numberStyle,
header = header,
headerStyle = headerStyle,
headerColor = headerColor,
indicator = indicator,
indicatorColor = indicatorColor,
)
}
}
@Composable
private fun CenteredDayNumber(
date: LocalDate,
textColor: Color,
numberStyle: TextStyle,
indicator: Boolean,
indicatorColor: Color,
) {
Box(modifier = Modifier.fillMaxSize()) {
BasicText(
text = date.dayOfMonth.toString(),
style =
RecipeTheme.typography.label.copy(
color = textColor,
fontWeight = FontWeight.SemiBold,
),
style = numberStyle.copy(color = textColor),
modifier = Modifier.align(Alignment.Center),
)
if (indicator) {
Spacer(modifier = Modifier.height(2.dp))
Box(
modifier =
Modifier
.size(4.dp)
.clip(CircleShape)
.background(indicatorColor),
IndicatorDot(
color = indicatorColor,
modifier = Modifier.align(Alignment.Center).offset(y = 11.dp),
)
}
}
}
@Composable
private fun HeaderDayNumber(
date: LocalDate,
textColor: Color,
numberStyle: TextStyle,
header: String,
headerStyle: TextStyle,
headerColor: Color,
indicator: Boolean,
indicatorColor: Color,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(text = header, style = headerStyle.copy(color = headerColor))
Spacer(modifier = Modifier.height(1.dp))
BasicText(
text = date.dayOfMonth.toString(),
style = numberStyle.copy(color = textColor),
)
}
if (indicator) {
IndicatorDot(
color = indicatorColor,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
)
}
}
}
@Composable
private fun IndicatorDot(
color: Color,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.size(4.dp)
.clip(CircleShape)
.background(color),
)
}
private fun Modifier.dayCellSurface(
backgroundColor: Color,
ringColor: Color,
): Modifier =
this
.background(backgroundColor, CircleShape)
.then(
if (ringColor == Color.Transparent) {
Modifier
} else {
Modifier.border(1.dp, ringColor, CircleShape)
},
)
private const val INDICATOR_MUTED_ALPHA = 0.6f

View File

@@ -0,0 +1,22 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Composable
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.calendar_horizon_days
import recipe.composeapp.generated.resources.calendar_horizon_today
@Composable
fun horizonLabel(
today: LocalDate,
end: LocalDate,
): String {
val days = (today.daysUntil(end) + 1).coerceAtLeast(1)
return if (days == 1) {
stringResource(Res.string.calendar_horizon_today)
} else {
stringResource(Res.string.calendar_horizon_days, days)
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
@@ -37,6 +38,7 @@ internal fun WeekdayHeader(
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
fontWeight = FontWeight.Light,
),
)
}

View File

@@ -0,0 +1,249 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import dev.ulfrx.recipe.ui.theme.lerp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
@Composable
fun CalendarPill(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
label: String = "",
collapsedContent: (@Composable RowScope.() -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
dayState: (LocalDate) -> DayState = { DayState() },
pillHeight: Dp = 48.dp,
locale: CalendarLocale = CalendarLocale.PL,
) {
val scope = rememberCoroutineScope()
val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) }
LaunchedEffect(expanded) {
expansion.animateTo(scope, target = if (expanded) 1f else 0f)
}
val progress = expansion.progress
val cornerRadius = pillHeight / 2 * (1f - progress) + EXPANDED_CORNER_RADIUS * progress
val glassStyle = lerp(RecipeTheme.glass.menu, RecipeTheme.glass.panel, progress)
val pillInset = RecipeTheme.spacing.lg + RecipeTheme.spacing.xs
val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() }
val dragState =
rememberDraggableState { delta ->
expansion.dragBy(delta, range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f))
}
GlassSurface(
modifier =
modifier.draggable(
state = dragState,
orientation = Orientation.Vertical,
onDragStarted = { expansion.cancelSettle() },
onDragStopped = { velocity ->
val openTarget = releaseTarget(expansion.progress, velocity)
val range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f)
expansion.animateTo(scope, if (openTarget) 1f else 0f, initialVelocity = -velocity / range)
if (openTarget != expanded) onExpandedChange(openTarget)
},
),
cornerRadius = cornerRadius,
glassStyle = glassStyle,
) {
Box(modifier = Modifier.fillMaxWidth()) {
CompositionLocalProvider(LocalCalendarInteractive provides expanded) {
Box(
modifier = Modifier.fillMaxWidth().expandingHeight(progress, pillHeight, expansion).alpha(progress),
) {
SwipeableCalendar(
selectedDate = selectedDate,
today = today,
mode = CalendarMode.Month,
onSelectDate = onSelectDate,
onModeChange = {},
onVisibleAnchorChange = {},
dayState = dayState,
expandable = false,
locale = locale,
modifier = Modifier.fillMaxWidth().padding(vertical = RecipeTheme.spacing.lg),
)
}
}
val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f)
if (rowAlpha > 0f) {
Box(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.alpha(rowAlpha),
) {
PillRow(
label = label,
collapsedContent = collapsedContent,
trailing = trailing,
height = pillHeight,
horizontalInset = pillInset,
)
}
}
}
}
}
@Composable
private fun PillRow(
label: String,
collapsedContent: (@Composable RowScope.() -> Unit)?,
trailing: (@Composable () -> Unit)?,
height: Dp,
horizontalInset: Dp,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = horizontalInset),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
if (collapsedContent != null) {
collapsedContent()
} else {
BasicText(
text = label,
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
trailing?.invoke()
}
}
}
/**
* Measures the calendar at its full intrinsic height, reports it to [expansion]
* so drag knows the range, then lays out at the lerped height anchored to the
* bottom edge so the calendar slides down from above the pill row.
*/
private fun Modifier.expandingHeight(
progress: Float,
pillHeight: Dp,
expansion: PillExpansion,
): Modifier =
this.layout { measurable, constraints ->
val placeable =
measurable.measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
expansion.reportFullHeight(placeable.height)
val pillHeightPx = pillHeight.roundToPx()
val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height)
layout(placeable.width, height) {
placeable.place(0, height - placeable.height)
}
}
/**
* Single source of truth for pill drag/settle state. Holds [progress] (0 =
* collapsed, 1 = expanded) and tracks [target] so external [expanded] changes
* that match an in-flight settle become no-ops — no flag, no race.
*/
@Stable
private class PillExpansion(initial: Float) {
var progress by mutableFloatStateOf(initial)
private set
var fullHeightPx by mutableIntStateOf(0)
private set
private var target: Float = initial
private var settleJob: Job? = null
fun dragBy(delta: Float, range: Float) {
settleJob?.cancel()
progress = (progress - delta / range).coerceIn(0f, 1f)
target = progress
}
fun animateTo(scope: CoroutineScope, target: Float, initialVelocity: Float = 0f) {
if (this.target == target && settleJob?.isActive == true) return
this.target = target
settleJob?.cancel()
settleJob =
scope.launch {
Animatable(progress).also { it.updateBounds(0f, 1f) }
.animateTo(
targetValue = target,
animationSpec =
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
initialVelocity = initialVelocity,
) { progress = value }
}
}
fun cancelSettle() {
settleJob?.cancel()
}
fun reportFullHeight(height: Int) {
if (fullHeightPx != height) fullHeightPx = height
}
}
private fun releaseTarget(
progress: Float,
velocity: Float,
): Boolean =
when {
velocity <= -FLING_VELOCITY -> true
velocity >= FLING_VELOCITY -> false
else -> progress >= 0.5f
}
private const val FLING_VELOCITY = 60f
private const val PILL_CONTENT_FADE_END = 0.35f
private val EXPANDED_CORNER_RADIUS = 28.dp

View File

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Whether the calendar shows a single week strip or the full month grid.
@@ -8,6 +9,13 @@ import androidx.compose.runtime.Immutable
*/
enum class CalendarMode { Week, Month }
/**
* Day-cell interactivity gate. CalendarPill flips this to `false` while
* collapsed so the always-composed month grid (kept in the tree to feed drag
* its full height) doesn't catch taps that visually belong to the pill row.
*/
internal val LocalCalendarInteractive = staticCompositionLocalOf { true }
/**
* Per-day visual modifiers resolved by the caller. Selection and "today"
* outline are handled by the surface itself and must not be set here.

View File

@@ -0,0 +1,49 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
@Immutable
data class HorizonCalendarState(
val selectedDate: LocalDate,
val isCalendarOpen: Boolean = false,
)
/**
* Shared state holder for "pick a horizon date" screens (Pantry, Shopping).
* Owns the date + open flag and enforces "no past dates" on selection. Lives
* inside the owning ViewModel as a plain field — not a ViewModel itself.
*
* [today] is parameterised so tests can pin the clock.
*/
class HorizonCalendarHolder(
initialDate: LocalDate = defaultHorizon(),
private val today: () -> LocalDate = ::todayInSystemTz,
) {
private val _state = MutableStateFlow(HorizonCalendarState(selectedDate = initialDate))
val state: StateFlow<HorizonCalendarState> = _state.asStateFlow()
fun setOpen(open: Boolean) {
_state.update { it.copy(isCalendarOpen = open) }
}
fun close() = setOpen(false)
fun select(date: LocalDate) {
if (date < today()) return
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
}
companion object {
private const val DEFAULT_HORIZON_DAYS = 7
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate =
today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
}
}

View File

@@ -0,0 +1,31 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.datetime.LocalDate
@Composable
fun HorizonCalendarPill(
selectedDate: LocalDate,
expanded: Boolean,
today: LocalDate,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
trailing: @Composable () -> Unit,
) {
CalendarPill(
label = horizonLabel(today, selectedDate),
expanded = expanded,
onExpandedChange = onExpandedChange,
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
trailing = trailing,
dayState = { date ->
if (date < today) DayState(disabled = true, dimmed = true) else DayState()
},
modifier = modifier.fillMaxWidth(),
)
}

View File

@@ -0,0 +1,86 @@
package dev.ulfrx.recipe.ui.components.overlay
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
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.theme.RecipeTheme
/**
* Scaffold for a bottom-anchored modal overlay (calendar pill today; future
* bottom-sheets, filter panels). Owns four crosscuts so screens don't repeat
* them:
* - **Local glass backdrop** — Liquid refraction filters the nearest
* liquefiable ancestor, so the overlay must be a sibling of its own
* backdrop source (not a descendant of the shell's global one).
* - **Scrim** — tap-outside dismisses while [open] is true.
* - **Tab/route exit** — closes the overlay on dispose to keep state honest
* when the user navigates away mid-open.
* - **Active-tab tap** — registers with [OverlayDismisser] so a tap on the
* already-active tab in the shell closes us too.
*/
@Composable
fun BottomOverlayScaffold(
open: Boolean,
onDismiss: () -> Unit,
bottomInset: Dp,
modifier: Modifier = Modifier,
overlay: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
val backdrop = rememberGlassBackdropState()
val latestOnDismiss by rememberUpdatedState(onDismiss)
val latestOpen by rememberUpdatedState(open)
DisposableEffect(Unit) {
onDispose { if (latestOpen) latestOnDismiss() }
}
RegisterDismissibleOverlay(active = open, onDismiss = onDismiss)
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
Box(modifier = modifier.fillMaxSize()) {
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background)) {
content()
}
}
if (open) {
Box(
modifier =
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { onDismiss() }
},
)
}
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = RecipeTheme.spacing.xl)
.padding(bottom = bottomInset),
) {
overlay()
}
}
}
}

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.components.overlay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
@Stable
class OverlayDismisser {
private val handlers = mutableListOf<() -> Unit>()
fun register(onDismiss: () -> Unit): () -> Unit {
handlers += onDismiss
return { handlers -= onDismiss }
}
fun dismissAll() {
handlers.toList().forEach { it() }
}
}
val LocalOverlayDismisser = staticCompositionLocalOf<OverlayDismisser> {
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
}
@Composable
fun RegisterDismissibleOverlay(
active: Boolean,
onDismiss: () -> Unit,
) {
val dismisser = LocalOverlayDismisser.current
val latestOnDismiss by rememberUpdatedState(onDismiss)
DisposableEffect(dismisser, active) {
val unregister = if (active) dismisser.register { latestOnDismiss() } else null
onDispose { unregister?.invoke() }
}
}

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.pantry_shortfall_count
@Composable
fun PantryHorizonPill(
selectedDate: LocalDate,
expanded: Boolean,
today: LocalDate,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
HorizonCalendarPill(
selectedDate = selectedDate,
expanded = expanded,
today = today,
onExpandedChange = onExpandedChange,
onSelectDate = onSelectDate,
trailing = {
BasicText(
text = stringResource(Res.string.pantry_shortfall_count, DUMMY_SHORTFALLS),
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.destructive),
maxLines = 1,
)
},
modifier = modifier,
)
}
private const val DUMMY_SHORTFALLS = 7

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_pantry_subtitle
import recipe.composeapp.generated.resources.empty_pantry_title
import recipe.composeapp.generated.resources.shell_tab_pantry
/**
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
* empty body with the inventory list.
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable
fun PantryScreen(viewModel: PantryViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
BottomOverlayScaffold(
open = horizonState.isCalendarOpen,
onDismiss = viewModel.horizon::close,
bottomInset = rememberShellChromeHeight(),
overlay = {
PantryHorizonPill(
selectedDate = horizonState.selectedDate,
expanded = horizonState.isCalendarOpen,
today = today,
onExpandedChange = viewModel.horizon::setOpen,
onSelectDate = viewModel.horizon::select,
)
},
) {
Column(
modifier =

View File

@@ -1,19 +1,8 @@
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [PantryScreen]. Phase 2.1 ships only the empty state. Phase 8
* (Pantry) extends this with inventory rows + actions.
*/
data class PantryState(
val isEmpty: Boolean = true,
)
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
class PantryViewModel : ViewModel() {
private val _state = MutableStateFlow(PantryState())
val state: StateFlow<PantryState> = _state.asStateFlow()
val horizon = HorizonCalendarHolder()
}

View File

@@ -0,0 +1,62 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.ulfrx.recipe.ui.components.calendar.CalendarLocale
import dev.ulfrx.recipe.ui.components.calendar.CalendarPill
import dev.ulfrx.recipe.ui.components.calendar.DayState
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
@Composable
fun PlannerCalendarPill(
selectedDate: LocalDate,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val today = remember { todayInSystemTz() }
val locale = CalendarLocale.PL
val plannedDummy =
remember(today) {
setOf(today, today.plus(DatePeriod(days = 1)), today.plus(DatePeriod(days = 3)))
}
val dayState =
remember(plannedDummy) {
{ date: LocalDate -> DayState(indicator = date in plannedDummy) }
}
val pillTextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light, fontSize = 12.sp)
CalendarPill(
expanded = expanded,
onExpandedChange = onExpandedChange,
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
collapsedContent = {
PlannerWeekStrip(
selectedDate = selectedDate,
today = today,
onSelectDate = onSelectDate,
numberStyle = pillTextStyle,
dayState = dayState,
modifier = Modifier.weight(1f),
)
BasicText(
text = locale.monthsShort[selectedDate.monthNumber - 1],
style = pillTextStyle.copy(color = RecipeTheme.colors.contentMuted),
)
},
dayState = dayState,
modifier = modifier.fillMaxWidth(),
)
}

View File

@@ -1,81 +1,66 @@
package dev.ulfrx.recipe.ui.screens.planner
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.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.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.ui.components.calendar.SwipeableCalendar
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
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
/**
* Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
* in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable
fun PlannerScreen(viewModel: PlannerViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
Box(
modifier =
Modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
BottomOverlayScaffold(
open = state.isCalendarOpen,
onDismiss = viewModel::closeCalendar,
bottomInset = rememberShellChromeHeight(),
overlay = {
PlannerCalendarPill(
selectedDate = state.selectedDate,
expanded = state.isCalendarOpen,
onExpandedChange = viewModel::setCalendarOpen,
onSelectDate = viewModel::selectDate,
)
},
) {
Column(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style =
RecipeTheme.typography.title.copy(
color = RecipeTheme.colors.content,
),
modifier =
Modifier.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
),
)
Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg))
SwipeableCalendar(
selectedDate = state.selectedDate,
today = today,
mode = state.calendarMode,
onSelectDate = viewModel::selectDate,
onModeChange = viewModel::setCalendarMode,
// Swipe auto-follows: dropping into a new week/month bumps
// the selection by the same offset (kotlinx.datetime clamps
// day-of-month for short months).
onVisibleAnchorChange = viewModel::selectDate,
expandable = true,
modifier = Modifier.fillMaxWidth(),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = DockDestination.Planner.icon,
title = stringResource(Res.string.empty_planner_title),
subtitle = stringResource(Res.string.empty_planner_subtitle),
)
}
}
}
}

View File

@@ -1,7 +1,6 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.calendar.CalendarMode
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -9,31 +8,24 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
* extends this with day-plan data, meal slot actions, and pantry/shortfall
* derivations driven by [selectedDate].
*/
data class PlannerState(
val selectedDate: LocalDate,
val calendarMode: CalendarMode,
val isCalendarOpen: Boolean = false,
)
class PlannerViewModel : ViewModel() {
private val _state =
MutableStateFlow(
PlannerState(
selectedDate = todayInSystemTz(),
calendarMode = CalendarMode.Week,
),
)
private val _state = MutableStateFlow(PlannerState(selectedDate = todayInSystemTz()))
val state: StateFlow<PlannerState> = _state.asStateFlow()
fun selectDate(date: LocalDate) {
_state.update { it.copy(selectedDate = date) }
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
}
fun setCalendarMode(mode: CalendarMode) {
_state.update { it.copy(calendarMode = mode) }
fun setCalendarOpen(open: Boolean) {
_state.update { it.copy(isCalendarOpen = open) }
}
fun closeCalendar() {
_state.update { it.copy(isCalendarOpen = false) }
}
}

View File

@@ -0,0 +1,48 @@
package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.components.calendar.CalendarDayCell
import dev.ulfrx.recipe.ui.components.calendar.CalendarLocale
import dev.ulfrx.recipe.ui.components.calendar.DayState
import dev.ulfrx.recipe.ui.components.calendar.weekStripDays
import kotlinx.datetime.LocalDate
@Composable
fun PlannerWeekStrip(
selectedDate: LocalDate,
today: LocalDate,
onSelectDate: (LocalDate) -> Unit,
numberStyle: TextStyle,
modifier: Modifier = Modifier,
dayState: (LocalDate) -> DayState = { DayState() },
locale: CalendarLocale = CalendarLocale.PL,
) {
val days = weekStripDays(selectedDate)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
days.forEachIndexed { index, day ->
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = dayState(day),
isSelected = day == selectedDate,
isToday = day == today,
onClick = { onSelectDate(day) },
numberStyle = numberStyle,
header = locale.weekdaysShort[index],
)
}
}
}
}

View File

@@ -109,14 +109,14 @@ fun RecipeDetailSheet(
ModalBottomSheet(state = sheetState) {
Scrim(
scrimColor = ScrimColor,
enter = fadeIn(tween(ScrimFadeMillis)),
exit = fadeOut(tween(ScrimFadeMillis)),
scrimColor = SCRIM_COLOR,
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
)
Sheet(
modifier = Modifier.fillMaxWidth(),
backgroundColor = RecipeTheme.colors.background,
shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius),
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
) {
ready?.let {
RecipeDetailContent(
@@ -161,8 +161,8 @@ private fun BottomSheetScope.RecipeDetailContent(
style =
typography.display.copy(
color = colors.content,
fontSize = TitleTextSize,
lineHeight = TitleLineHeight,
fontSize = TITLE_TEXT_SIZE,
lineHeight = TITLE_LINE_HEIGHT,
fontWeight = FontWeight.Bold,
),
)
@@ -199,8 +199,8 @@ private fun BottomSheetScope.RecipeDetailContent(
.semantics { contentDescription = handleLabel }
.clip(RoundedCornerShape(percent = 50))
.background(colors.surface.copy(alpha = 0.85f))
.width(HandleWidth)
.height(HandleHeight),
.width(HANDLE_WIDTH)
.height(HANDLE_HEIGHT),
)
PlanButton(
@@ -217,7 +217,7 @@ private fun BottomSheetScope.RecipeDetailContent(
@Composable
private fun RecipeHero() {
val colors = RecipeTheme.colors
Box(modifier = Modifier.fillMaxWidth().height(HeroHeight)) {
Box(modifier = Modifier.fillMaxWidth().height(HERO_HEIGHT)) {
Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass))
Image(
painter = painterResource(Res.drawable.sample_recipe),
@@ -253,7 +253,7 @@ private fun MetaRow(minutes: Int) {
imageVector = Lucide.Clock,
contentDescription = null,
tint = colors.contentMuted,
modifier = Modifier.size(MetaIconSize),
modifier = Modifier.size(META_ICON_SIZE),
)
BasicText(
text = stringResource(Res.string.recipe_card_minutes_format, minutes),
@@ -279,8 +279,8 @@ private fun SectionTitle(text: String) {
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
fontSize = SectionHeaderTextSize,
letterSpacing = SectionHeaderTracking,
fontSize = SECTION_HEADER_TEXT_SIZE,
letterSpacing = SECTION_HEADER_TRACKING,
fontWeight = FontWeight.Bold,
),
)
@@ -320,7 +320,7 @@ private fun IngredientsSection(
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
) {
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
Column(verticalArrangement = Arrangement.spacedBy(IngredientRowGap)) {
Column(verticalArrangement = Arrangement.spacedBy(INGREDIENT_ROW_GAP)) {
ingredients.forEach { slot ->
IngredientRow(
slot = slot.scaledBy(servings),
@@ -377,7 +377,7 @@ private fun ServingsStepper(
color = colors.content,
fontWeight = FontWeight.SemiBold,
),
modifier = Modifier.width(ServingsValueWidth),
modifier = Modifier.width(SERVINGS_VALUE_WIDTH),
)
StepperButton(
icon = Lucide.Plus,
@@ -399,14 +399,14 @@ private fun StepperButton(
UnstyledButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(StepperButtonSize),
modifier = Modifier.size(STEPPER_BUTTON_SIZE),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
UnstyledIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
modifier = Modifier.size(StepperIconSize),
modifier = Modifier.size(STEPPER_ICON_SIZE),
)
}
}
@@ -425,9 +425,9 @@ private fun StepRow(
RecipeTheme.typography.body.copy(
color = colors.contentMuted,
fontWeight = FontWeight.Medium,
fontSize = StepTextSize,
fontSize = STEP_TEXT_SIZE,
),
modifier = Modifier.width(StepNumberWidth),
modifier = Modifier.width(STEP_NUMBER_WIDTH),
)
BasicText(
text = text,
@@ -435,8 +435,8 @@ private fun StepRow(
RecipeTheme.typography.body.copy(
color = colors.content,
fontWeight = FontWeight.Normal,
fontSize = StepTextSize,
lineHeight = StepLineHeight,
fontSize = STEP_TEXT_SIZE,
lineHeight = STEP_LINE_HEIGHT,
),
modifier = Modifier.weight(1f),
)
@@ -450,8 +450,8 @@ private fun PlanButton(
) {
val colors = RecipeTheme.colors
GlassSurface(
modifier = modifier.height(PlanButtonHeight),
cornerRadius = PlanButtonHeight / 2,
modifier = modifier.height(PLAN_BUTTON_HEIGHT),
cornerRadius = PLAN_BUTTON_HEIGHT / 2,
tint = colors.surfaceGlass,
) {
UnstyledButton(
@@ -469,7 +469,7 @@ private fun PlanButton(
imageVector = Lucide.Calendar,
contentDescription = null,
tint = colors.content,
modifier = Modifier.size(PlanButtonIconSize),
modifier = Modifier.size(PLAN_BUTTON_ICON_SIZE),
)
BasicText(
text = stringResource(Res.string.recipe_detail_plan_button),
@@ -485,25 +485,25 @@ private fun PlanButton(
}
private const val SHEET_HEIGHT_FRACTION = 0.92f
private const val ScrimFadeMillis = 250
private const val SCRIM_FADE_MILLIS = 250
private val ScrimColor = Color.Black.copy(alpha = 0.45f)
private val SheetCornerRadius = 28.dp
private val HeroHeight = 200.dp
private val HandleWidth = 36.dp
private val HandleHeight = 5.dp
private val IngredientRowGap = 6.dp
private val MetaIconSize = 14.dp
private val StepperButtonSize = 30.dp
private val StepperIconSize = 14.dp
private val ServingsValueWidth = 28.dp
private val StepNumberWidth = 20.dp
private val PlanButtonHeight = 36.dp
private val PlanButtonIconSize = 14.dp
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
private val SHEET_CORNER_RADIUS = 28.dp
private val HERO_HEIGHT = 200.dp
private val HANDLE_WIDTH = 36.dp
private val HANDLE_HEIGHT = 5.dp
private val INGREDIENT_ROW_GAP = 6.dp
private val META_ICON_SIZE = 14.dp
private val STEPPER_BUTTON_SIZE = 30.dp
private val STEPPER_ICON_SIZE = 14.dp
private val SERVINGS_VALUE_WIDTH = 28.dp
private val STEP_NUMBER_WIDTH = 20.dp
private val PLAN_BUTTON_HEIGHT = 36.dp
private val PLAN_BUTTON_ICON_SIZE = 14.dp
private val TitleTextSize = 24.sp
private val TitleLineHeight = 28.sp
private val SectionHeaderTextSize = 11.sp
private val SectionHeaderTracking = 1.sp
private val StepTextSize = 14.sp
private val StepLineHeight = 20.sp
private val TITLE_TEXT_SIZE = 24.sp
private val TITLE_LINE_HEIGHT = 28.sp
private val SECTION_HEADER_TEXT_SIZE = 11.sp
private val SECTION_HEADER_TRACKING = 1.sp
private val STEP_TEXT_SIZE = 14.sp
private val STEP_LINE_HEIGHT = 20.sp

View File

@@ -15,10 +15,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res

View File

@@ -23,10 +23,12 @@ import dev.ulfrx.recipe.navigation.TabNavigator
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.overlay.LocalOverlayDismisser
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -40,8 +42,12 @@ fun AppShell(modifier: Modifier = Modifier) {
val catalogGridState = rememberLazyGridState()
val searchState by searchVm.state.collectAsStateWithLifecycle()
val backdropState = rememberGlassBackdropState()
val overlayDismisser = remember { OverlayDismisser() }
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
CompositionLocalProvider(
LocalGlassBackdropState provides backdropState,
LocalOverlayDismisser provides overlayDismisser,
) {
Box(
modifier =
modifier
@@ -79,7 +85,10 @@ fun AppShell(modifier: Modifier = Modifier) {
ShellBottomChrome(
activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab,
onTabSelect = { tab ->
overlayDismisser.dismissAll()
navigator.selectTab(tab)
},
search =
SearchHandlers(
state = searchState,

View File

@@ -111,7 +111,7 @@ fun ShellBottomChrome(
) {
AnimatedContent(
targetState = search.state.isOpen,
modifier = Modifier.fillMaxWidth().height(63.dp),
modifier = Modifier.fillMaxWidth().height(DockBandHeight),
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
@@ -165,9 +165,9 @@ private fun DockRow(
collapsed = false,
onTabSelect = onTabSelect,
modifier = Modifier.weight(1f),
height = 63.dp,
height = DockBandHeight,
)
Box(modifier = Modifier.size(63.dp)) {
Box(modifier = Modifier.size(DockBandHeight)) {
FloatingSearchButton(onClick = onSearchTap)
}
}

View File

@@ -0,0 +1,21 @@
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
internal val DockBandHeight: Dp = 63.dp
@Composable
fun rememberShellChromeHeight(): Dp {
val spacing = RecipeTheme.spacing
val navBottom =
with(LocalDensity.current) {
(WindowInsets.navigationBars.getBottom(this) / 2).toDp()
}
return navBottom + spacing.xs + DockBandHeight + spacing.sm
}

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.shopping_buy_count
@Composable
fun ShoppingHorizonPill(
selectedDate: LocalDate,
expanded: Boolean,
today: LocalDate,
onExpandedChange: (Boolean) -> Unit,
onSelectDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
HorizonCalendarPill(
selectedDate = selectedDate,
expanded = expanded,
today = today,
onExpandedChange = onExpandedChange,
onSelectDate = onSelectDate,
trailing = {
BasicText(
text = stringResource(Res.string.shopping_buy_count, DUMMY_TO_BUY),
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.contentMuted),
maxLines = 1,
)
},
modifier = modifier,
)
}
private const val DUMMY_TO_BUY = 12

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_shopping_subtitle
import recipe.composeapp.generated.resources.empty_shopping_title
import recipe.composeapp.generated.resources.shell_tab_shopping
/**
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
* empty body with the shopping list + session UI.
*
* Search is shell-wide; this screen owns no bottom-chrome state.
*/
@Composable
fun ShoppingScreen(viewModel: ShoppingViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
BottomOverlayScaffold(
open = horizonState.isCalendarOpen,
onDismiss = viewModel.horizon::close,
bottomInset = rememberShellChromeHeight(),
overlay = {
ShoppingHorizonPill(
selectedDate = horizonState.selectedDate,
expanded = horizonState.isCalendarOpen,
today = today,
onExpandedChange = viewModel.horizon::setOpen,
onSelectDate = viewModel.horizon::select,
)
},
) {
Column(
modifier =

View File

@@ -1,19 +1,8 @@
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [ShoppingScreen]. Phase 2.1 ships only the empty state. Phase 9
* (Shopping List & Session Log) extends this with list items + session actions.
*/
data class ShoppingState(
val isEmpty: Boolean = true,
)
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
class ShoppingViewModel : ViewModel() {
private val _state = MutableStateFlow(ShoppingState())
val state: StateFlow<ShoppingState> = _state.asStateFlow()
val horizon = HorizonCalendarHolder()
}

View File

@@ -37,15 +37,15 @@ public val LightRecipeColors: RecipeColors =
public val DarkRecipeColors: RecipeColors =
RecipeColors(
background = Color(0xFF0F1113),
surface = Color(0xFF1A1D21),
surfaceGlass = Color(0xFF3A3D42).copy(alpha = 0.55f),
background = Color(0xFF1E2024),
surface = Color(0xFF2A2D31),
surfaceGlass = Color(0xFF494D53).copy(alpha = 0.55f),
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.12f),
content = Color(0xFFF1EFEA),
contentMuted = Color(0xFF9AA0A6),
accent = Color(0xFFE48A6E),
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
separator = Color(0xFF2A2D31),
separator = Color(0xFF383B40),
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
destructive = Color(0xFFE57368),
)

View File

@@ -2,8 +2,10 @@ package dev.ulfrx.recipe.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
data object RecipeGlass {
/** Strong refraction tuned for thin chrome elements (dock, pills, search bar). */
val menu: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.10f,
@@ -25,6 +27,18 @@ data object RecipeGlass {
contrast = 1.0f,
frost = 0.dp,
)
/** Calm refraction with strong frost — for large surfaces where [menu] would read as a murky lens. */
val panel: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.03f,
curve = 0.25f,
edge = 0.01f,
dispersion = 0.0f,
saturation = 0.5f,
contrast = 1.3f,
frost = 28.dp,
)
}
data class RecipeGlassStyle(
@@ -36,3 +50,18 @@ data class RecipeGlassStyle(
val contrast: Float,
val frost: Dp,
)
fun lerp(
start: RecipeGlassStyle,
stop: RecipeGlassStyle,
fraction: Float,
): RecipeGlassStyle =
RecipeGlassStyle(
refraction = lerp(start.refraction, stop.refraction, fraction),
curve = lerp(start.curve, stop.curve, fraction),
edge = lerp(start.edge, stop.edge, fraction),
dispersion = lerp(start.dispersion, stop.dispersion, fraction),
saturation = lerp(start.saturation, stop.saturation, fraction),
contrast = lerp(start.contrast, stop.contrast, fraction),
frost = lerp(start.frost.value, stop.frost.value, fraction).dp,
)