diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8ed9a4b..5469504 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt index 67e4c60..8089557 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavDisplay.kt @@ -71,24 +71,25 @@ fun RootNavDisplay( backStack = navigator.backStackFor(tab), modifier = Modifier.fillMaxSize(), onBack = { navigator.goBack(tab) }, - entryProvider = entryProvider { - entry { - val vm: HomeViewModel = koinViewModel() - HomeScreen(viewModel = vm) - } - entry { - val vm: PlannerViewModel = koinViewModel() - PlannerScreen(viewModel = vm) - } - entry { - val vm: PantryViewModel = koinViewModel() - PantryScreen(viewModel = vm) - } - entry { - val vm: ShoppingViewModel = koinViewModel() - ShoppingScreen(viewModel = vm) - } - }, + entryProvider = + entryProvider { + entry { + val vm: HomeViewModel = koinViewModel() + HomeScreen(viewModel = vm) + } + entry { + val vm: PlannerViewModel = koinViewModel() + PlannerScreen(viewModel = vm) + } + entry { + val vm: PantryViewModel = koinViewModel() + PantryScreen(viewModel = vm) + } + entry { + val vm: ShoppingViewModel = koinViewModel() + ShoppingScreen(viewModel = vm) + } + }, ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt index ed49d8a..cb8f41b 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/TabNavigator.kt @@ -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 get() = backStacks.getValue(activeTab) - fun backStackFor(tab: DockDestination): SnapshotStateList = - backStacks.getValue(tab) + fun backStackFor(tab: DockDestination): SnapshotStateList = backStacks.getValue(tab) fun selectTab(tab: DockDestination) { if (tab == activeTab) { diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDates.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDates.kt new file mode 100644 index 0000000..5f22418 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDates.kt @@ -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 { + 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 { + 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)) + } 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 new file mode 100644 index 0000000..f8799da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarDayCell.kt @@ -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), + ) + } + } +} 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 new file mode 100644 index 0000000..6bbbaa8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarGrid.kt @@ -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) }, + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTopbar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTopbar.kt new file mode 100644 index 0000000..896cbb3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTopbar.kt @@ -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), + ) + } + } + } + } +} 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 new file mode 100644 index 0000000..417b2a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/CalendarTypes.kt @@ -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, + val monthsLong: List, + val monthsShort: List, +) { + 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", + ), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/SwipeableCalendar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/SwipeableCalendar.kt new file mode 100644 index 0000000..d73896c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/calendar/SwipeableCalendar.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt index 6e188fc..a9ad356 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt @@ -11,9 +11,10 @@ import io.github.fletchmckee.liquid.LiquidState import io.github.fletchmckee.liquid.liquefiable import io.github.fletchmckee.liquid.rememberLiquidState -val LocalGlassBackdropState = staticCompositionLocalOf { - error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource") -} +val LocalGlassBackdropState = + staticCompositionLocalOf { + error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource") + } @Stable class GlassBackdropState internal constructor( @@ -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, 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 dad70a0..687c16f 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,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 = - 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)), + + 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(), ) } - Box( - modifier = - Modifier - .size(20.dp) - .padding(end = 0.dp), - ) } } 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 08a8fad..dc1a905 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,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 = _state.asStateFlow() + + fun selectDate(date: LocalDate) { + _state.update { it.copy(selectedDate = date) } + } + + fun setCalendarMode(mode: CalendarMode) { + _state.update { it.copy(calendarMode = mode) } + } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt index 8b93b45..52d802a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/search/ShellSearchViewModel.kt @@ -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 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 8bb09d9..d2986d3 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 @@ -69,14 +69,15 @@ fun AppShell(modifier: Modifier = Modifier) { ShellBottomChrome( activeTab = navigator.activeTab, onTabSelect = navigator::selectTab, - search = SearchHandlers( - state = searchState, - onOpen = searchVm::open, - onQueryChange = searchVm::onQueryChange, - onClose = searchVm::close, - onFocus = searchVm::focus, - onUnfocus = searchVm::unfocus, - ), + search = + SearchHandlers( + state = searchState, + onOpen = searchVm::open, + onQueryChange = searchVm::onQueryChange, + onClose = searchVm::close, + onFocus = searchVm::focus, + onUnfocus = searchVm::unfocus, + ), modifier = Modifier.align(Alignment.BottomCenter), ) } 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 ceb83bb..9fcc899 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 @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt similarity index 77% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt index 2bff98d..fcee1b4 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt @@ -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,27 +78,30 @@ private fun DockBarExpanded( val activeIndex = destinations.indexOf(active).coerceAtLeast(0) Box( - modifier = modifier - .height(height) - .onSizeChanged { dockWidthPx = it.width.toFloat() } - .pointerInput(destinations) { - trackDockGesture { event -> - when (event) { - is DockPressEvent.Pressing -> { - pressState = DockPressState.Pressing(event.xPx) - } - is DockPressEvent.Released -> { - tabIndexAt(event.xPx, tabBounds)?.let { idx -> - onTabSelect(destinations[idx]) + modifier = + modifier + .height(height) + .onSizeChanged { dockWidthPx = it.width.toFloat() } + .pointerInput(destinations) { + trackDockGesture { event -> + when (event) { + 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 } - pressState = DockPressState.Idle - } - DockPressEvent.Cancelled -> { - pressState = DockPressState.Idle } } - } - }, + }, ) { DockSubstrate(cornerRadius = height / 2) DockActiveIndicatorLayer( diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGeometry.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGeometry.kt similarity index 80% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGeometry.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGeometry.kt index d3f884f..4423d3a 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGeometry.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGeometry.kt @@ -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? { +internal fun tabIndexAt( + x: Float, + bounds: Map, +): Int? { if (bounds.isEmpty()) return null val sorted = bounds.entries.sortedBy { it.value.offsetXPx } var result = sorted.first().key diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGesture.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGesture.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt index 8d62445..e6ee652 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGesture.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockLayers.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockLayers.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt index 1e78c61..3bd400c 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockLayers.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockTabRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockTabRow.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt index ebad459..2ba9508 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockTabRow.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockTabRow.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt index 2af2cdd..eb91c62 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/FloatingSearchButton.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchChrome.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchChrome.kt index 5767ae7..6a3b17b 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchChrome.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchChrome.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchControls.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchControls.kt index 660f4a5..a1852fd 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchControls.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchControls.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt rename to composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt index c93e649..c35c7f8 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/search/SearchPill.kt @@ -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 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 c52f66e..60ec7a1 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 @@ -4,25 +4,27 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp data object RecipeGlass { - val menu: RecipeGlassStyle = RecipeGlassStyle( - refraction = 0.10f, - curve = 0.5f, - edge = 0.04f, - dispersion = 0.05f, - saturation = 0.5f, - contrast = 1.3f, - frost = 15.dp, - ) + val menu: RecipeGlassStyle = + RecipeGlassStyle( + refraction = 0.10f, + curve = 0.5f, + edge = 0.04f, + dispersion = 0.05f, + saturation = 0.5f, + contrast = 1.3f, + frost = 15.dp, + ) - val dockPress: RecipeGlassStyle = RecipeGlassStyle( - refraction = 0.20f, - curve = 0.05f, - edge = 0.04f, - dispersion = 0.03f, - saturation = 0.6f, - contrast = 1.8f, - frost = 0.dp, - ) + val dockPress: RecipeGlassStyle = + RecipeGlassStyle( + refraction = 0.20f, + curve = 0.05f, + edge = 0.04f, + dispersion = 0.03f, + saturation = 0.6f, + contrast = 1.8f, + frost = 0.dp, + ) } data class RecipeGlassStyle( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 900cb2e..ec82715 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }