Add calendar in PlannerScreen
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -11,7 +11,8 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user