Add calendar in PlannerScreen

This commit is contained in:
2026-05-18 17:02:34 +02:00
parent fb00df856a
commit ab1630a06b
26 changed files with 884 additions and 245 deletions

View File

@@ -89,6 +89,7 @@ kotlin {
implementation(libs.ktor.clientLogging)
implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson)
implementation(libs.kotlinx.datetime)
implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui)

View File

@@ -71,7 +71,8 @@ fun RootNavDisplay(
backStack = navigator.backStackFor(tab),
modifier = Modifier.fillMaxSize(),
onBack = { navigator.goBack(tab) },
entryProvider = entryProvider {
entryProvider =
entryProvider {
entry<Screen.Home.Root> {
val vm: HomeViewModel = koinViewModel()
HomeScreen(viewModel = vm)

View File

@@ -2,10 +2,10 @@ package dev.ulfrx.recipe.navigation
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
@Stable
class TabNavigator(
@@ -20,8 +20,7 @@ class TabNavigator(
val activeBackStack: SnapshotStateList<Screen>
get() = backStacks.getValue(activeTab)
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> =
backStacks.getValue(tab)
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
fun selectTab(tab: DockDestination) {
if (tab == activeTab) {

View File

@@ -0,0 +1,119 @@
package dev.ulfrx.recipe.ui.components.calendar
import kotlinx.datetime.Clock
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
/** Today in the system time zone. */
fun todayInSystemTz(): LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
/** Monday-anchored start of the ISO week containing [date]. */
fun LocalDate.startOfWeekMonday(): LocalDate {
val diff = dayOfWeek.ordinal - DayOfWeek.MONDAY.ordinal
return this.minus(DatePeriod(days = diff))
}
/** First day of the month containing [date]. */
fun LocalDate.startOfMonth(): LocalDate = LocalDate(year, month, 1)
/**
* Returns 42 consecutive days starting from the Monday on/before the 1st of
* [anchor]'s month — i.e., the 6-week visible grid. Anchor's month always
* starts on the first row; trailing rows fill from the next month.
*/
fun monthGridDays(anchor: LocalDate): List<LocalDate> {
val gridStart = anchor.startOfMonth().startOfWeekMonday()
return List(42) { i -> gridStart.plus(DatePeriod(days = i)) }
}
/** Seven days starting from Monday of [anchor]'s week. */
fun weekStripDays(anchor: LocalDate): List<LocalDate> {
val start = anchor.startOfWeekMonday()
return List(7) { i -> start.plus(DatePeriod(days = i)) }
}
/** Formats the visible-period label rendered in the topbar pill. */
fun formatPeriodLabel(
mode: CalendarMode,
anchor: LocalDate,
locale: CalendarLocale,
): String =
when (mode) {
CalendarMode.Month -> {
"${locale.monthsLong[anchor.monthNumber - 1]} ${anchor.year}"
}
CalendarMode.Week -> {
val start = anchor.startOfWeekMonday()
val end = start.plus(DatePeriod(days = 6))
when {
start.year == end.year && start.monthNumber == end.monthNumber -> {
"${start.dayOfMonth}${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
start.year == end.year -> {
"${start.dayOfMonth} ${locale.monthsShort[start.monthNumber - 1]} " +
"${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
else -> {
"${start.dayOfMonth} ${locale.monthsShort[start.monthNumber - 1]} ${start.year} " +
"${end.dayOfMonth} ${locale.monthsShort[end.monthNumber - 1]} ${end.year}"
}
}
}
}
/** True when [date] is inside the period visible at [anchor] under [mode]. */
fun isInVisiblePeriod(
date: LocalDate,
anchor: LocalDate,
mode: CalendarMode,
): Boolean =
when (mode) {
CalendarMode.Month -> {
date.year == anchor.year && date.monthNumber == anchor.monthNumber
}
CalendarMode.Week -> {
val start = anchor.startOfWeekMonday()
val end = start.plus(DatePeriod(days = 6))
date in start..end
}
}
/**
* Whole-unit offset between [a] and [b] under [mode] (signed b - a). Used to
* map between the surface's pager index and an anchor date.
*/
fun periodsBetween(
a: LocalDate,
b: LocalDate,
mode: CalendarMode,
): Int =
when (mode) {
CalendarMode.Month -> {
(b.year - a.year) * 12 + (b.monthNumber - a.monthNumber)
}
CalendarMode.Week -> {
val startDays = a.startOfWeekMonday().toEpochDays()
val endDays = b.startOfWeekMonday().toEpochDays()
(endDays - startDays) / 7
}
}
/** Advance [date] by [delta] units of [mode]. */
fun LocalDate.plusPeriods(
delta: Int,
mode: CalendarMode,
): LocalDate =
when (mode) {
CalendarMode.Month -> this.plus(DatePeriod(months = delta))
CalendarMode.Week -> this.plus(DatePeriod(days = delta * 7))
}

View File

@@ -0,0 +1,131 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.font.FontWeight
import androidx.compose.ui.unit.dp
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,
state: DayState,
isSelected: Boolean,
isToday: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
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 =
when {
state.disabled -> mutedColor.copy(alpha = 0.45f)
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
isSelected -> accent
else -> baseColor
}
val ringColor: Color =
when {
isSelected -> accent.copy(alpha = 0.55f)
isToday -> baseColor.copy(alpha = 0.35f)
else -> Color.Transparent
}
val cellModifier =
modifier
.height(36.dp)
.fillMaxWidth()
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,
) {
DayCellInner(
date = date,
textColor = textColor,
indicator = state.indicator,
indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = 0.65f),
)
}
}
@Composable
private fun DayCellInner(
date: LocalDate,
textColor: Color,
indicator: Boolean,
indicatorColor: Color,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
text = date.dayOfMonth.toString(),
style =
RecipeTheme.typography.label.copy(
color = textColor,
fontWeight = FontWeight.SemiBold,
),
)
if (indicator) {
Spacer(modifier = Modifier.height(2.dp))
Box(
modifier =
Modifier
.size(4.dp)
.clip(CircleShape)
.background(indicatorColor),
)
}
}
}

View File

@@ -0,0 +1,121 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
private val DAY_SPACING = 4.dp
private val WEEK_SPACING = 4.dp
/** Weekday-letter header row. */
@Composable
internal fun WeekdayHeader(
locale: CalendarLocale,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
locale.weekdaysShort.forEach { label ->
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
BasicText(
text = label,
style =
RecipeTheme.typography.label.copy(
color = RecipeTheme.colors.contentMuted,
),
)
}
}
}
}
/**
* Seven-day Monday-first strip for [anchor]'s week. All days are in-period so
* the [DayState.dimmed] flag is never set by this composable itself.
*/
@Composable
internal fun WeekStrip(
anchor: LocalDate,
today: LocalDate,
dayState: (LocalDate) -> DayState,
isSelected: (LocalDate) -> Boolean,
onSelect: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val days = weekStripDays(anchor)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
days.forEach { day ->
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = dayState(day),
isSelected = isSelected(day),
isToday = day == today,
onClick = { onSelect(day) },
)
}
}
}
}
/**
* Fixed 6-week grid for [anchor]'s month. Adjacent-month days are auto-marked
* dimmed (caller's [dayState] does not need to set that flag for them).
*/
@Composable
internal fun MonthGrid(
anchor: LocalDate,
today: LocalDate,
dayState: (LocalDate) -> DayState,
isSelected: (LocalDate) -> Boolean,
onSelect: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val days = monthGridDays(anchor)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(WEEK_SPACING),
) {
for (week in 0 until 6) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DAY_SPACING),
) {
for (dayIdx in 0 until 7) {
val day = days[week * 7 + dayIdx]
val inMonth = day.monthNumber == anchor.monthNumber
val resolved = dayState(day)
val effective =
if (!inMonth) resolved.copy(dimmed = true) else resolved
Box(modifier = Modifier.weight(1f)) {
CalendarDayCell(
date = day,
state = effective,
isSelected = isSelected(day),
isToday = day == today,
onClick = { onSelect(day) },
)
}
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledIcon
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.datetime.LocalDate
/**
* Pill button showing the visible period label. Tapping jumps to today and
* selects it. Optional chevron at the end toggles week/month when [expandable]
* is set; the chevron is hidden otherwise so popup variants get a clean pill.
*/
@Composable
internal fun CalendarTopbar(
mode: CalendarMode,
anchor: LocalDate,
today: LocalDate,
selectedDate: LocalDate,
locale: CalendarLocale,
onJumpToToday: () -> Unit,
expandable: Boolean,
onToggleMode: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = RecipeTheme.colors
val onToday = selectedDate == today && isInVisiblePeriod(today, anchor, mode)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
UnstyledButton(
onClick = onJumpToToday,
enabled = !onToday,
backgroundColor = Color.Transparent,
contentColor = colors.content,
shape = CircleShape,
borderColor = colors.separator,
borderWidth = 1.dp,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
modifier = Modifier.defaultMinSize(minHeight = 32.dp),
) {
BasicText(
text = formatPeriodLabel(mode, anchor, locale),
style =
RecipeTheme.typography.label.copy(
color = if (onToday) colors.contentMuted else colors.content,
),
modifier = if (onToday) Modifier.alpha(0.6f) else Modifier,
)
}
if (expandable) {
Spacer(modifier = Modifier.size(8.dp))
UnstyledButton(
onClick = onToggleMode,
backgroundColor = Color.Transparent,
contentColor = colors.content,
shape = CircleShape,
borderColor = colors.separator,
borderWidth = 1.dp,
contentPadding = PaddingValues(6.dp),
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) {
UnstyledIcon(
imageVector = Lucide.ChevronDown,
contentDescription = null,
tint = colors.contentMuted,
modifier =
Modifier
.size(14.dp)
.rotate(if (mode == CalendarMode.Month) 180f else 0f),
)
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.runtime.Immutable
/**
* Whether the calendar shows a single week strip or the full month grid.
* Planner uses both with a toggle; Pantry/Shopping popups stay on [Month].
*/
enum class CalendarMode { Week, Month }
/**
* Per-day visual modifiers resolved by the caller. Selection and "today"
* outline are handled by the surface itself and must not be set here.
*
* @param dimmed Day belongs to an adjacent month in the 6-week grid.
* @param disabled Day is non-interactive (e.g., past dates in Pantry).
* @param indicator Render a small dot under the date number (e.g., "has meal").
*/
@Immutable
data class DayState(
val dimmed: Boolean = false,
val disabled: Boolean = false,
val indicator: Boolean = false,
)
/**
* Localized strings for the calendar. Hardcoded to Polish in v1 (REQ-LOC-PL).
* Externalize to string resources when other locales arrive.
*/
@Immutable
data class CalendarLocale(
val weekdaysShort: List<String>,
val monthsLong: List<String>,
val monthsShort: List<String>,
) {
companion object {
val PL: CalendarLocale =
CalendarLocale(
weekdaysShort = listOf("pn", "wt", "śr", "cz", "pt", "so", "nd"),
monthsLong =
listOf(
"Styczeń",
"Luty",
"Marzec",
"Kwiecień",
"Maj",
"Czerwiec",
"Lipiec",
"Sierpień",
"Wrzesień",
"Październik",
"Listopad",
"Grudzień",
),
monthsShort =
listOf(
"sty",
"lut",
"mar",
"kwi",
"maj",
"cze",
"lip",
"sie",
"wrz",
"paź",
"lis",
"gru",
),
)
}
}

View File

@@ -0,0 +1,168 @@
package dev.ulfrx.recipe.ui.components.calendar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.LocalDate
/**
* Reusable calendar surface for planner, pantry, and shopping. One swipe-able
* paged carousel of week strips or month grids, plus an optional chevron to
* toggle between the two modes.
*
* The composable is **controlled** — anchor/selection/mode live in the
* caller's state. The pager is local UI state and is re-keyed when [mode]
* changes (so the new origin date can be picked up safely).
*
* @param selectedDate Currently selected day. Defaults to the only highlight
* used by [isSelectedOverride]'s default impl. Tapping the topbar pill jumps
* here.
* @param today Used for the "today" outline ring; also the date the topbar
* jumps to when tapped.
* @param mode Whether to render week strips or month grids.
* @param onSelectDate Called when the user taps a day cell.
* @param onModeChange Called when the user taps the expand chevron.
* @param onVisibleAnchorChange Called when the user swipes to a new period.
* Receives an anchor inside the now-visible period. The caller usually
* updates [selectedDate] in response (see PlannerViewModel for the pattern).
* @param dayState Per-day visual modifiers (dimmed for adjacent-month days is
* added automatically by the month grid).
* @param isSelectedOverride Custom selection predicate. Pass for range
* selection; defaults to `date == selectedDate`.
* @param expandable When true, renders the chevron and supports mode toggle.
* Popup variants (pantry/shopping) set this to false.
*/
@Composable
fun SwipeableCalendar(
selectedDate: LocalDate,
today: LocalDate,
mode: CalendarMode,
onSelectDate: (LocalDate) -> Unit,
onModeChange: (CalendarMode) -> Unit,
onVisibleAnchorChange: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
dayState: (LocalDate) -> DayState = { DayState() },
isSelectedOverride: ((LocalDate) -> Boolean)? = null,
expandable: Boolean = true,
locale: CalendarLocale = CalendarLocale.PL,
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp),
) {
val isSelected: (LocalDate) -> Boolean =
isSelectedOverride ?: { it == selectedDate }
val currentOnAnchorChange by rememberUpdatedState(onVisibleAnchorChange)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
// Re-key the pager block on mode so we can pick a fresh origin from
// the currently-selected date. The pager state is local; the caller
// never needs to scroll it manually.
key(mode) {
val origin = remember { selectedDate }
val initialPage = remember { INITIAL_PAGE }
val pagerState = rememberPagerState(initialPage = initialPage) { PAGE_COUNT }
CalendarTopbar(
mode = mode,
anchor = origin.plusPeriods(pagerState.currentPage - initialPage, mode),
today = today,
selectedDate = selectedDate,
locale = locale,
onJumpToToday = { onSelectDate(today) },
expandable = expandable,
onToggleMode = {
onModeChange(
if (mode == CalendarMode.Month) CalendarMode.Week else CalendarMode.Month,
)
},
modifier = Modifier.padding(contentPadding),
)
// Bring the pager onto the page that contains [selectedDate]
// whenever it changes externally (e.g., tap "today" on the topbar
// or a fresh selection from the page we're already on).
LaunchedEffect(selectedDate) {
val target = initialPage + periodsBetween(origin, selectedDate, mode)
if (target != pagerState.currentPage) {
pagerState.animateScrollToPage(target)
}
}
// Report swipe-driven anchor changes upward so the caller can keep
// its own selection in sync (e.g., planner auto-follows the week).
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect { page ->
if (page == initialPage) return@collect
val anchor = origin.plusPeriods(page - initialPage, mode)
if (!isInVisiblePeriod(selectedDate, anchor, mode)) {
currentOnAnchorChange(anchor)
}
}
}
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
WeekdayHeader(locale = locale)
HorizontalPager(
state = pagerState,
pageSpacing = 0.dp,
flingBehavior =
PagerDefaults.flingBehavior(
state = pagerState,
),
modifier = Modifier.fillMaxWidth(),
) { page ->
val pageAnchor = origin.plusPeriods(page - initialPage, mode)
Box(modifier = Modifier.fillMaxWidth()) {
when (mode) {
CalendarMode.Week -> {
WeekStrip(
anchor = pageAnchor,
today = today,
dayState = dayState,
isSelected = isSelected,
onSelect = onSelectDate,
)
}
CalendarMode.Month -> {
MonthGrid(
anchor = pageAnchor,
today = today,
dayState = dayState,
isSelected = isSelected,
onSelect = onSelectDate,
)
}
}
}
}
}
}
}
}
// Centered start lets the pager scroll forward and backward freely while
// keeping page indices small enough for the underlying lazy list. 100k pages
// in either direction is ~1900 years — far beyond any reasonable navigation.
private const val PAGE_COUNT: Int = 200_000
private const val INITIAL_PAGE: Int = PAGE_COUNT / 2

View File

@@ -11,7 +11,8 @@ import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.rememberLiquidState
val LocalGlassBackdropState = staticCompositionLocalOf<GlassBackdropState> {
val LocalGlassBackdropState =
staticCompositionLocalOf<GlassBackdropState> {
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
}
@@ -29,7 +30,11 @@ fun rememberGlassBackdropState(): GlassBackdropState {
}
@Composable
fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
fun GlassBackdropSource(
state: GlassBackdropState,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier.liquefiable(state.liquidState),
content = content,

View File

@@ -1,197 +1,81 @@
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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.ui.components.calendar.SwipeableCalendar
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.shell_tab_planner
/**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
* empty body with the calendar grid.
* 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) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
val bgDark = Color(0xFF14181F)
val titleColor = Color(0xFFE8E4DC)
val today = remember { todayInSystemTz() }
Box(
modifier =
Modifier
.fillMaxSize()
.background(bgDark),
.background(RecipeTheme.colors.background),
) {
// Scrollable, visually rich content sitting behind the glass chrome.
// Bottom contentPadding extends well past the dock so items keep
// scrolling under it (the whole point of this test view).
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
contentPadding =
PaddingValues(
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
top = RecipeTheme.spacing.xl + 48.dp,
bottom = 160.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = GlassTestItems, key = { it.id }) { item ->
GlassTestCard(item = item)
}
}
// Title pinned at the top so the chrome glass doesn't have to refract
// over the very top of the scrollable list.
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style = RecipeTheme.typography.title.copy(color = titleColor),
modifier =
Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
),
)
}
}
private data class GlassTestItem(
val id: Int,
val accent: Color,
val cardTone: Color,
val titleWeight: Float,
val subtitleWeight: Float,
)
private val GlassTestItems: List<GlassTestItem> =
run {
val accents =
listOf(
Color(0xFFD97757), // accent terracotta
Color(0xFF6EA987), // sage
Color(0xFF7A8FB8), // dusty blue
Color(0xFFC1864F), // amber
Color(0xFFB76E79), // muted rose
Color(0xFF6B7A8F), // slate
Color(0xFF8E7CC3), // muted violet
)
val tones =
listOf(
Color(0xFF1F242C),
Color(0xFF232932),
Color(0xFF1B2028),
Color(0xFF272D36),
)
List(40) { i ->
GlassTestItem(
id = i,
accent = accents[i % accents.size],
cardTone = tones[i % tones.size],
titleWeight = 0.80f + ((i * 13) % 20) / 100f,
subtitleWeight = 0.55f + ((i * 7) % 40) / 100f,
)
}
}
@Composable
private fun GlassTestCard(item: GlassTestItem) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(88.dp)
.clip(RoundedCornerShape(16.dp))
.background(item.cardTone),
) {
// Left accent stripe — varied saturated colors so the dock chrome
// gets to refract a clear hue band as you scroll past.
Box(
modifier =
Modifier
.width(6.dp)
.fillMaxSize()
.background(item.accent),
)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 12.dp + 6.dp,
end = 12.dp,
top = 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
.windowInsetsPadding(WindowInsets.statusBars),
) {
// Title bar
Box(
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style =
RecipeTheme.typography.title.copy(
color = RecipeTheme.colors.content,
),
modifier =
Modifier
.fillMaxWidth(item.titleWeight)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
Modifier.padding(
top = RecipeTheme.spacing.xl,
start = RecipeTheme.spacing.lg,
end = RecipeTheme.spacing.lg,
),
)
// Subtitle bar
Box(
modifier =
Modifier
.fillMaxWidth(item.subtitleWeight)
.height(10.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
)
Spacer(modifier = Modifier.height(2.dp))
// Faint metadata dot + bar
Box(
modifier =
Modifier
.fillMaxWidth(0.18f)
.height(8.dp)
.clip(RoundedCornerShape(2.dp))
.background(item.accent.copy(alpha = 0.55f)),
)
}
Box(
modifier =
Modifier
.size(20.dp)
.padding(end = 0.dp),
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(),
)
}
}
}

View File

@@ -1,19 +1,39 @@
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
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state. Phase 6
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
* 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 isEmpty: Boolean = true,
val selectedDate: LocalDate,
val calendarMode: CalendarMode,
)
class PlannerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlannerState())
private val _state =
MutableStateFlow(
PlannerState(
selectedDate = todayInSystemTz(),
calendarMode = CalendarMode.Week,
),
)
val state: StateFlow<PlannerState> = _state.asStateFlow()
fun selectDate(date: LocalDate) {
_state.update { it.copy(selectedDate = date) }
}
fun setCalendarMode(mode: CalendarMode) {
_state.update { it.copy(calendarMode = mode) }
}
}

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe.ui.screens.search
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.ui.components.search.SearchState
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

View File

@@ -69,7 +69,8 @@ fun AppShell(modifier: Modifier = Modifier) {
ShellBottomChrome(
activeTab = navigator.activeTab,
onTabSelect = navigator::selectTab,
search = SearchHandlers(
search =
SearchHandlers(
state = searchState,
onOpen = searchVm::open,
onQueryChange = searchVm::onQueryChange,

View File

@@ -24,10 +24,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
import dev.ulfrx.recipe.ui.components.search.SearchState
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.screens.shell.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.screens.shell.search.SearchPillRow
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
@@ -78,7 +78,8 @@ private fun DockBarExpanded(
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
Box(
modifier = modifier
modifier =
modifier
.height(height)
.onSizeChanged { dockWidthPx = it.width.toFloat() }
.pointerInput(destinations) {
@@ -87,12 +88,14 @@ private fun DockBarExpanded(
is DockPressEvent.Pressing -> {
pressState = DockPressState.Pressing(event.xPx)
}
is DockPressEvent.Released -> {
tabIndexAt(event.xPx, tabBounds)?.let { idx ->
onTabSelect(destinations[idx])
}
pressState = DockPressState.Idle
}
DockPressEvent.Cancelled -> {
pressState = DockPressState.Idle
}

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@@ -13,12 +13,21 @@ internal data class TabBounds(
internal sealed interface DockPressState {
data object Idle : DockPressState
data class Pressing(val xPx: Float) : DockPressState
data class Pressing(
val xPx: Float,
) : DockPressState
}
internal sealed interface DockPressEvent {
data class Pressing(val xPx: Float) : DockPressEvent
data class Released(val xPx: Float) : DockPressEvent
data class Pressing(
val xPx: Float,
) : DockPressEvent
data class Released(
val xPx: Float,
) : DockPressEvent
data object Cancelled : DockPressEvent
}
@@ -42,7 +51,10 @@ internal fun activeIndicatorBboxFor(
return ActiveIndicatorBbox(left, right)
}
internal fun tabIndexAt(x: Float, bounds: Map<Int, TabBounds>): Int? {
internal fun tabIndexAt(
x: Float,
bounds: Map<Int, TabBounds>,
): Int? {
if (bounds.isEmpty()) return null
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
var result = sorted.first().key

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -13,9 +13,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Cancelled
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Pressing
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Released
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Cancelled
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released
private const val OverlaySlideDurationMs = 200

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.dock
package dev.ulfrx.recipe.ui.screens.shell.dock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
@@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
/**
* Shell-wide search state shape, exposed by

View File

@@ -1,4 +1,4 @@
package dev.ulfrx.recipe.ui.components.search
package dev.ulfrx.recipe.ui.screens.shell.search
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable

View File

@@ -4,7 +4,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data object RecipeGlass {
val menu: RecipeGlassStyle = RecipeGlassStyle(
val menu: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.10f,
curve = 0.5f,
edge = 0.04f,
@@ -14,7 +15,8 @@ data object RecipeGlass {
frost = 15.dp,
)
val dockPress: RecipeGlassStyle = RecipeGlassStyle(
val dockPress: RecipeGlassStyle =
RecipeGlassStyle(
refraction = 0.20f,
curve = 0.05f,
edge = 0.04f,

View File

@@ -14,6 +14,7 @@ koin = "4.2.1"
koin-plugin = "1.0.0-RC2"
kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.2"
kotlinx-serialization = "1.7.3"
ktor = "3.4.2"
lokksmith = "0.13.0"
@@ -33,6 +34,7 @@ kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", versio
# kotlinx.serialization (shared DTOs — D-27)
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }