diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 5ec1345..9d85a64 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -68,4 +68,12 @@
Wkrótce zobaczysz tu wszystko, co masz pod ręką.
Lista zakupów czeka na Twój plan
Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.
+
+
+ Tylko dziś
+ Najbliższe %1$d dni
+
+
+ %1$d braków
+ %1$d do kupienia
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
index 00ec737..d216b81 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDayCell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDayCell.kt
index f8799da..cd878a9 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDayCell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDayCell.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarFormat.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarFormat.kt
new file mode 100644
index 0000000..d78a46e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarFormat.kt
@@ -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)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarGrid.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarGrid.kt
index 6bbbaa8..c5b4f99 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarGrid.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarGrid.kt
@@ -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,
),
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt
new file mode 100644
index 0000000..f7d730a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTypes.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTypes.kt
index 417b2a0..cafa466 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTypes.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTypes.kt
@@ -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.
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt
new file mode 100644
index 0000000..f4a05b6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt
@@ -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 = _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))
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarPill.kt
new file mode 100644
index 0000000..ff70e32
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarPill.kt
@@ -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(),
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/BottomOverlayScaffold.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/BottomOverlayScaffold.kt
new file mode 100644
index 0000000..fa95859
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/BottomOverlayScaffold.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt
new file mode 100644
index 0000000..84c6946
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt
@@ -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 {
+ 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() }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryHorizonPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryHorizonPill.kt
new file mode 100644
index 0000000..9a3f153
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryHorizonPill.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt
index 992bec1..b079ff7 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt
@@ -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 =
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt
index a96653c..23591e8 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt
@@ -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 = _state.asStateFlow()
+ val horizon = HorizonCalendarHolder()
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt
new file mode 100644
index 0000000..6e52ada
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt
@@ -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(),
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
index 687c16f..c05f72a 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
@@ -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),
+ )
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
index dc1a905..0ddf3aa 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
@@ -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 = _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) }
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt
new file mode 100644
index 0000000..983912f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt
@@ -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],
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
index f70f67a..5bf79fa 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipedetail/RecipeDetailSheet.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
index 87bed4c..9a6c6f3 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/SearchScreen.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
index 1ad1fe4..964ce88 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt
index 9fcc899..968b24a 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellBottomChrome.kt
@@ -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)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChromeMetrics.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChromeMetrics.kt
new file mode 100644
index 0000000..c5e9e01
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChromeMetrics.kt
@@ -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
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingHorizonPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingHorizonPill.kt
new file mode 100644
index 0000000..7b3d2aa
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingHorizonPill.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt
index 90f89f6..ac4cac0 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt
@@ -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 =
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt
index e009e6e..1573b2a 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt
@@ -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 = _state.asStateFlow()
+ val horizon = HorizonCalendarHolder()
}
diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
index 2f61f75..a936afc 100644
--- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
+++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeGlass.kt
@@ -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,
+ )