From 22b43050d6da4d5934cd1a5936922ccef139cddc Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Thu, 28 May 2026 23:12:53 +0200 Subject: [PATCH] Implement calendar pill widgets --- .../composeResources/values/strings.xml | 8 + .../kotlin/dev/ulfrx/recipe/di/ShellModule.kt | 2 +- .../ui/components/calendar/CalendarDayCell.kt | 210 +++++++++++---- .../ui/components/calendar/CalendarFormat.kt | 22 ++ .../ui/components/calendar/CalendarGrid.kt | 2 + .../ui/components/calendar/CalendarPill.kt | 249 ++++++++++++++++++ .../ui/components/calendar/CalendarTypes.kt | 8 + .../calendar/HorizonCalendarHolder.kt | 49 ++++ .../calendar/HorizonCalendarPill.kt | 31 +++ .../overlay/BottomOverlayScaffold.kt | 86 ++++++ .../ui/components/overlay/OverlayDismisser.kt | 39 +++ .../ui/screens/pantry/PantryHorizonPill.kt | 39 +++ .../recipe/ui/screens/pantry/PantryScreen.kt | 30 ++- .../ui/screens/pantry/PantryViewModel.kt | 15 +- .../ui/screens/planner/PlannerCalendarPill.kt | 62 +++++ .../ui/screens/planner/PlannerScreen.kt | 77 +++--- .../ui/screens/planner/PlannerViewModel.kt | 26 +- .../ui/screens/planner/PlannerWeekStrip.kt | 48 ++++ .../screens/recipedetail/RecipeDetailSheet.kt | 86 +++--- .../recipe/ui/screens/search/SearchScreen.kt | 4 +- .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 15 +- .../ui/screens/shell/ShellBottomChrome.kt | 6 +- .../ui/screens/shell/ShellChromeMetrics.kt | 21 ++ .../screens/shopping/ShoppingHorizonPill.kt | 39 +++ .../ui/screens/shopping/ShoppingScreen.kt | 30 ++- .../ui/screens/shopping/ShoppingViewModel.kt | 15 +- .../dev/ulfrx/recipe/ui/theme/RecipeGlass.kt | 29 ++ 27 files changed, 1028 insertions(+), 220 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarFormat.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarPill.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarHolder.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/HorizonCalendarPill.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/BottomOverlayScaffold.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/overlay/OverlayDismisser.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryHorizonPill.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerCalendarPill.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerWeekStrip.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellChromeMetrics.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingHorizonPill.kt 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, + )