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.clientLogging)
implementation(libs.ktor.serializationKotlinxJsonMpp) implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson) implementation(libs.kotlinx.serializationJson)
implementation(libs.kotlinx.datetime)
implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings)
implementation(libs.lokksmith.compose) implementation(libs.lokksmith.compose)
implementation(libs.navigation3.ui) implementation(libs.navigation3.ui)

View File

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

View File

@@ -2,10 +2,10 @@ package dev.ulfrx.recipe.navigation
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
@Stable @Stable
class TabNavigator( class TabNavigator(
@@ -20,8 +20,7 @@ class TabNavigator(
val activeBackStack: SnapshotStateList<Screen> val activeBackStack: SnapshotStateList<Screen>
get() = backStacks.getValue(activeTab) get() = backStacks.getValue(activeTab)
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
backStacks.getValue(tab)
fun selectTab(tab: DockDestination) { fun selectTab(tab: DockDestination) {
if (tab == activeTab) { 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,9 +11,10 @@ import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable import io.github.fletchmckee.liquid.liquefiable
import io.github.fletchmckee.liquid.rememberLiquidState import io.github.fletchmckee.liquid.rememberLiquidState
val LocalGlassBackdropState = staticCompositionLocalOf<GlassBackdropState> { val LocalGlassBackdropState =
staticCompositionLocalOf<GlassBackdropState> {
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource") error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
} }
@Stable @Stable
class GlassBackdropState internal constructor( class GlassBackdropState internal constructor(
@@ -29,7 +30,11 @@ fun rememberGlassBackdropState(): GlassBackdropState {
} }
@Composable @Composable
fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { fun GlassBackdropSource(
state: GlassBackdropState,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Box( Box(
modifier = modifier.liquefiable(state.liquidState), modifier = modifier.liquefiable(state.liquidState),
content = content, content = content,

View File

@@ -1,197 +1,81 @@
package dev.ulfrx.recipe.ui.screens.planner package dev.ulfrx.recipe.ui.screens.planner
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding 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.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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 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 dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.shell_tab_planner import recipe.composeapp.generated.resources.shell_tab_planner
/** /**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the * Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
* empty body with the calendar grid. * in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
* *
* Search is shell-wide; this screen owns no bottom-chrome state. * Search is shell-wide; this screen owns no bottom-chrome state.
*/ */
@Composable @Composable
fun PlannerScreen(viewModel: PlannerViewModel) { fun PlannerScreen(viewModel: PlannerViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val today = remember { todayInSystemTz() }
val bgDark = Color(0xFF14181F)
val titleColor = Color(0xFFE8E4DC)
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .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( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding( .windowInsetsPadding(WindowInsets.statusBars),
start = 12.dp + 6.dp,
end = 12.dp,
top = 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) { ) {
// Title bar BasicText(
Box( text = stringResource(Res.string.shell_tab_planner),
style =
RecipeTheme.typography.title.copy(
color = RecipeTheme.colors.content,
),
modifier = modifier =
Modifier Modifier.padding(
.fillMaxWidth(item.titleWeight) top = RecipeTheme.spacing.xl,
.height(14.dp) start = RecipeTheme.spacing.lg,
.clip(RoundedCornerShape(4.dp)) end = RecipeTheme.spacing.lg,
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)), ),
) )
// Subtitle bar
Box( Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg))
modifier =
Modifier SwipeableCalendar(
.fillMaxWidth(item.subtitleWeight) selectedDate = state.selectedDate,
.height(10.dp) today = today,
.clip(RoundedCornerShape(3.dp)) mode = state.calendarMode,
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)), onSelectDate = viewModel::selectDate,
) onModeChange = viewModel::setCalendarMode,
Spacer(modifier = Modifier.height(2.dp)) // Swipe auto-follows: dropping into a new week/month bumps
// Faint metadata dot + bar // the selection by the same offset (kotlinx.datetime clamps
Box( // day-of-month for short months).
modifier = onVisibleAnchorChange = viewModel::selectDate,
Modifier expandable = true,
.fillMaxWidth(0.18f) modifier = Modifier.fillMaxWidth(),
.height(8.dp)
.clip(RoundedCornerShape(2.dp))
.background(item.accent.copy(alpha = 0.55f)),
) )
} }
Box(
modifier =
Modifier
.size(20.dp)
.padding(end = 0.dp),
)
} }
} }

View File

@@ -1,19 +1,39 @@
package dev.ulfrx.recipe.ui.screens.planner package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 * UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
* (Meal Planner — Core Write Path) extends this with calendar data + actions. * extends this with day-plan data, meal slot actions, and pantry/shortfall
* derivations driven by [selectedDate].
*/ */
data class PlannerState( data class PlannerState(
val isEmpty: Boolean = true, val selectedDate: LocalDate,
val calendarMode: CalendarMode,
) )
class PlannerViewModel : ViewModel() { class PlannerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlannerState()) private val _state =
MutableStateFlow(
PlannerState(
selectedDate = todayInSystemTz(),
calendarMode = CalendarMode.Week,
),
)
val state: StateFlow<PlannerState> = _state.asStateFlow() 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 package dev.ulfrx.recipe.ui.screens.search
import androidx.lifecycle.ViewModel 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow

View File

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

View File

@@ -24,10 +24,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination import dev.ulfrx.recipe.navigation.DockDestination
import dev.ulfrx.recipe.ui.components.dock.DockBar import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton import dev.ulfrx.recipe.ui.screens.shell.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.components.search.SearchPillRow import dev.ulfrx.recipe.ui.screens.shell.search.SearchPillRow
import dev.ulfrx.recipe.ui.components.search.SearchState import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res 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.Box
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -78,7 +78,8 @@ private fun DockBarExpanded(
val activeIndex = destinations.indexOf(active).coerceAtLeast(0) val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
Box( Box(
modifier = modifier modifier =
modifier
.height(height) .height(height)
.onSizeChanged { dockWidthPx = it.width.toFloat() } .onSizeChanged { dockWidthPx = it.width.toFloat() }
.pointerInput(destinations) { .pointerInput(destinations) {
@@ -87,12 +88,14 @@ private fun DockBarExpanded(
is DockPressEvent.Pressing -> { is DockPressEvent.Pressing -> {
pressState = DockPressState.Pressing(event.xPx) pressState = DockPressState.Pressing(event.xPx)
} }
is DockPressEvent.Released -> { is DockPressEvent.Released -> {
tabIndexAt(event.xPx, tabBounds)?.let { idx -> tabIndexAt(event.xPx, tabBounds)?.let { idx ->
onTabSelect(destinations[idx]) onTabSelect(destinations[idx])
} }
pressState = DockPressState.Idle pressState = DockPressState.Idle
} }
DockPressEvent.Cancelled -> { DockPressEvent.Cancelled -> {
pressState = DockPressState.Idle 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.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -13,12 +13,21 @@ internal data class TabBounds(
internal sealed interface DockPressState { internal sealed interface DockPressState {
data object Idle : DockPressState data object Idle : DockPressState
data class Pressing(val xPx: Float) : DockPressState
data class Pressing(
val xPx: Float,
) : DockPressState
} }
internal sealed interface DockPressEvent { internal sealed interface DockPressEvent {
data class Pressing(val xPx: Float) : DockPressEvent data class Pressing(
data class Released(val xPx: Float) : DockPressEvent val xPx: Float,
) : DockPressEvent
data class Released(
val xPx: Float,
) : DockPressEvent
data object Cancelled : DockPressEvent data object Cancelled : DockPressEvent
} }
@@ -42,7 +51,10 @@ internal fun activeIndicatorBboxFor(
return ActiveIndicatorBbox(left, right) 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 if (bounds.isEmpty()) return null
val sorted = bounds.entries.sortedBy { it.value.offsetXPx } val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
var result = sorted.first().key 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.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
@@ -13,9 +13,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.positionChanged
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Cancelled import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Cancelled
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Pressing import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Released import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released
private const val OverlaySlideDurationMs = 200 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.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState 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.Arrangement
import androidx.compose.foundation.layout.Box 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.runtime.Composable
import androidx.compose.ui.Modifier 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.AnimatedVisibility
import androidx.compose.animation.EnterTransition 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.Lucide
import com.composables.icons.lucide.X import com.composables.icons.lucide.X
import dev.ulfrx.recipe.navigation.DockDestination 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.components.glass.CircleGlassButton
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
import dev.ulfrx.recipe.ui.theme.RecipeTheme import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res 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 * 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.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View File

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

View File

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