Add calendar in PlannerScreen
This commit is contained in:
@@ -89,6 +89,7 @@ kotlin {
|
||||
implementation(libs.ktor.clientLogging)
|
||||
implementation(libs.ktor.serializationKotlinxJsonMpp)
|
||||
implementation(libs.kotlinx.serializationJson)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.multiplatform.settings)
|
||||
implementation(libs.lokksmith.compose)
|
||||
implementation(libs.navigation3.ui)
|
||||
|
||||
@@ -71,7 +71,8 @@ fun RootNavDisplay(
|
||||
backStack = navigator.backStackFor(tab),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = { navigator.goBack(tab) },
|
||||
entryProvider = entryProvider {
|
||||
entryProvider =
|
||||
entryProvider {
|
||||
entry<Screen.Home.Root> {
|
||||
val vm: HomeViewModel = koinViewModel()
|
||||
HomeScreen(viewModel = vm)
|
||||
|
||||
@@ -2,10 +2,10 @@ package dev.ulfrx.recipe.navigation
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
@Stable
|
||||
class TabNavigator(
|
||||
@@ -20,8 +20,7 @@ class TabNavigator(
|
||||
val activeBackStack: SnapshotStateList<Screen>
|
||||
get() = backStacks.getValue(activeTab)
|
||||
|
||||
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> =
|
||||
backStacks.getValue(tab)
|
||||
fun backStackFor(tab: DockDestination): SnapshotStateList<Screen> = backStacks.getValue(tab)
|
||||
|
||||
fun selectTab(tab: DockDestination) {
|
||||
if (tab == activeTab) {
|
||||
|
||||
@@ -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,9 +11,10 @@ import io.github.fletchmckee.liquid.LiquidState
|
||||
import io.github.fletchmckee.liquid.liquefiable
|
||||
import io.github.fletchmckee.liquid.rememberLiquidState
|
||||
|
||||
val LocalGlassBackdropState = staticCompositionLocalOf<GlassBackdropState> {
|
||||
val LocalGlassBackdropState =
|
||||
staticCompositionLocalOf<GlassBackdropState> {
|
||||
error("LocalGlassBackdropState not provided — wrap in GlassBackdropSource")
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class GlassBackdropState internal constructor(
|
||||
@@ -29,7 +30,11 @@ fun rememberGlassBackdropState(): GlassBackdropState {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassBackdropSource(state: GlassBackdropState, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
|
||||
fun GlassBackdropSource(
|
||||
state: GlassBackdropState,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.liquefiable(state.liquidState),
|
||||
content = content,
|
||||
|
||||
@@ -1,197 +1,81 @@
|
||||
package dev.ulfrx.recipe.ui.screens.planner
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.ulfrx.recipe.ui.components.calendar.SwipeableCalendar
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||
|
||||
/**
|
||||
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
|
||||
* empty body with the calendar grid.
|
||||
* Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
|
||||
* in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
|
||||
*
|
||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
||||
*/
|
||||
@Composable
|
||||
fun PlannerScreen(viewModel: PlannerViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val bgDark = Color(0xFF14181F)
|
||||
val titleColor = Color(0xFFE8E4DC)
|
||||
val today = remember { todayInSystemTz() }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(bgDark),
|
||||
.background(RecipeTheme.colors.background),
|
||||
) {
|
||||
// Scrollable, visually rich content sitting behind the glass chrome.
|
||||
// Bottom contentPadding extends well past the dock so items keep
|
||||
// scrolling under it (the whole point of this test view).
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = RecipeTheme.spacing.lg,
|
||||
end = RecipeTheme.spacing.lg,
|
||||
top = RecipeTheme.spacing.xl + 48.dp,
|
||||
bottom = 160.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(items = GlassTestItems, key = { it.id }) { item ->
|
||||
GlassTestCard(item = item)
|
||||
}
|
||||
}
|
||||
|
||||
// Title pinned at the top so the chrome glass doesn't have to refract
|
||||
// over the very top of the scrollable list.
|
||||
BasicText(
|
||||
text = stringResource(Res.string.shell_tab_planner),
|
||||
style = RecipeTheme.typography.title.copy(color = titleColor),
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(
|
||||
top = RecipeTheme.spacing.xl,
|
||||
start = RecipeTheme.spacing.lg,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class GlassTestItem(
|
||||
val id: Int,
|
||||
val accent: Color,
|
||||
val cardTone: Color,
|
||||
val titleWeight: Float,
|
||||
val subtitleWeight: Float,
|
||||
)
|
||||
|
||||
private val GlassTestItems: List<GlassTestItem> =
|
||||
run {
|
||||
val accents =
|
||||
listOf(
|
||||
Color(0xFFD97757), // accent terracotta
|
||||
Color(0xFF6EA987), // sage
|
||||
Color(0xFF7A8FB8), // dusty blue
|
||||
Color(0xFFC1864F), // amber
|
||||
Color(0xFFB76E79), // muted rose
|
||||
Color(0xFF6B7A8F), // slate
|
||||
Color(0xFF8E7CC3), // muted violet
|
||||
)
|
||||
val tones =
|
||||
listOf(
|
||||
Color(0xFF1F242C),
|
||||
Color(0xFF232932),
|
||||
Color(0xFF1B2028),
|
||||
Color(0xFF272D36),
|
||||
)
|
||||
List(40) { i ->
|
||||
GlassTestItem(
|
||||
id = i,
|
||||
accent = accents[i % accents.size],
|
||||
cardTone = tones[i % tones.size],
|
||||
titleWeight = 0.80f + ((i * 13) % 20) / 100f,
|
||||
subtitleWeight = 0.55f + ((i * 7) % 40) / 100f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlassTestCard(item: GlassTestItem) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(88.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(item.cardTone),
|
||||
) {
|
||||
// Left accent stripe — varied saturated colors so the dock chrome
|
||||
// gets to refract a clear hue band as you scroll past.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(6.dp)
|
||||
.fillMaxSize()
|
||||
.background(item.accent),
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 12.dp + 6.dp,
|
||||
end = 12.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
// Title bar
|
||||
Box(
|
||||
BasicText(
|
||||
text = stringResource(Res.string.shell_tab_planner),
|
||||
style =
|
||||
RecipeTheme.typography.title.copy(
|
||||
color = RecipeTheme.colors.content,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.titleWeight)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.85f)),
|
||||
Modifier.padding(
|
||||
top = RecipeTheme.spacing.xl,
|
||||
start = RecipeTheme.spacing.lg,
|
||||
end = RecipeTheme.spacing.lg,
|
||||
),
|
||||
)
|
||||
// Subtitle bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(item.subtitleWeight)
|
||||
.height(10.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(Color(0xFFE8E4DC).copy(alpha = 0.40f)),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
// Faint metadata dot + bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.18f)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(item.accent.copy(alpha = 0.55f)),
|
||||
|
||||
Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg))
|
||||
|
||||
SwipeableCalendar(
|
||||
selectedDate = state.selectedDate,
|
||||
today = today,
|
||||
mode = state.calendarMode,
|
||||
onSelectDate = viewModel::selectDate,
|
||||
onModeChange = viewModel::setCalendarMode,
|
||||
// Swipe auto-follows: dropping into a new week/month bumps
|
||||
// the selection by the same offset (kotlinx.datetime clamps
|
||||
// day-of-month for short months).
|
||||
onVisibleAnchorChange = viewModel::selectDate,
|
||||
expandable = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.padding(end = 0.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
package dev.ulfrx.recipe.ui.screens.planner
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.ulfrx.recipe.ui.components.calendar.CalendarMode
|
||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state. Phase 6
|
||||
* (Meal Planner — Core Write Path) extends this with calendar data + actions.
|
||||
* UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
|
||||
* extends this with day-plan data, meal slot actions, and pantry/shortfall
|
||||
* derivations driven by [selectedDate].
|
||||
*/
|
||||
data class PlannerState(
|
||||
val isEmpty: Boolean = true,
|
||||
val selectedDate: LocalDate,
|
||||
val calendarMode: CalendarMode,
|
||||
)
|
||||
|
||||
class PlannerViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(PlannerState())
|
||||
private val _state =
|
||||
MutableStateFlow(
|
||||
PlannerState(
|
||||
selectedDate = todayInSystemTz(),
|
||||
calendarMode = CalendarMode.Week,
|
||||
),
|
||||
)
|
||||
val state: StateFlow<PlannerState> = _state.asStateFlow()
|
||||
|
||||
fun selectDate(date: LocalDate) {
|
||||
_state.update { it.copy(selectedDate = date) }
|
||||
}
|
||||
|
||||
fun setCalendarMode(mode: CalendarMode) {
|
||||
_state.update { it.copy(calendarMode = mode) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.ulfrx.recipe.ui.screens.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchState
|
||||
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
@@ -69,7 +69,8 @@ fun AppShell(modifier: Modifier = Modifier) {
|
||||
ShellBottomChrome(
|
||||
activeTab = navigator.activeTab,
|
||||
onTabSelect = navigator::selectTab,
|
||||
search = SearchHandlers(
|
||||
search =
|
||||
SearchHandlers(
|
||||
state = searchState,
|
||||
onOpen = searchVm::open,
|
||||
onQueryChange = searchVm::onQueryChange,
|
||||
|
||||
@@ -24,10 +24,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchPillRow
|
||||
import dev.ulfrx.recipe.ui.components.search.SearchState
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.FloatingSearchButton
|
||||
import dev.ulfrx.recipe.ui.screens.shell.search.SearchPillRow
|
||||
import dev.ulfrx.recipe.ui.screens.shell.search.SearchState
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
package dev.ulfrx.recipe.ui.screens.shell.dock
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -78,7 +78,8 @@ private fun DockBarExpanded(
|
||||
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier =
|
||||
modifier
|
||||
.height(height)
|
||||
.onSizeChanged { dockWidthPx = it.width.toFloat() }
|
||||
.pointerInput(destinations) {
|
||||
@@ -87,12 +88,14 @@ private fun DockBarExpanded(
|
||||
is DockPressEvent.Pressing -> {
|
||||
pressState = DockPressState.Pressing(event.xPx)
|
||||
}
|
||||
|
||||
is DockPressEvent.Released -> {
|
||||
tabIndexAt(event.xPx, tabBounds)?.let { idx ->
|
||||
onTabSelect(destinations[idx])
|
||||
}
|
||||
pressState = DockPressState.Idle
|
||||
}
|
||||
|
||||
DockPressEvent.Cancelled -> {
|
||||
pressState = DockPressState.Idle
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
package dev.ulfrx.recipe.ui.screens.shell.dock
|
||||
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -13,12 +13,21 @@ internal data class TabBounds(
|
||||
|
||||
internal sealed interface DockPressState {
|
||||
data object Idle : DockPressState
|
||||
data class Pressing(val xPx: Float) : DockPressState
|
||||
|
||||
data class Pressing(
|
||||
val xPx: Float,
|
||||
) : DockPressState
|
||||
}
|
||||
|
||||
internal sealed interface DockPressEvent {
|
||||
data class Pressing(val xPx: Float) : DockPressEvent
|
||||
data class Released(val xPx: Float) : DockPressEvent
|
||||
data class Pressing(
|
||||
val xPx: Float,
|
||||
) : DockPressEvent
|
||||
|
||||
data class Released(
|
||||
val xPx: Float,
|
||||
) : DockPressEvent
|
||||
|
||||
data object Cancelled : DockPressEvent
|
||||
}
|
||||
|
||||
@@ -42,7 +51,10 @@ internal fun activeIndicatorBboxFor(
|
||||
return ActiveIndicatorBbox(left, right)
|
||||
}
|
||||
|
||||
internal fun tabIndexAt(x: Float, bounds: Map<Int, TabBounds>): Int? {
|
||||
internal fun tabIndexAt(
|
||||
x: Float,
|
||||
bounds: Map<Int, TabBounds>,
|
||||
): Int? {
|
||||
if (bounds.isEmpty()) return null
|
||||
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
|
||||
var result = sorted.first().key
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
package dev.ulfrx.recipe.ui.screens.shell.dock
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
@@ -13,9 +13,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Cancelled
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Pressing
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Released
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Cancelled
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released
|
||||
|
||||
private const val OverlaySlideDurationMs = 200
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
package dev.ulfrx.recipe.ui.screens.shell.dock
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
package dev.ulfrx.recipe.ui.screens.shell.dock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.dock
|
||||
package dev.ulfrx.recipe.ui.screens.shell.dock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.search
|
||||
package dev.ulfrx.recipe.ui.screens.shell.search
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
@@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import dev.ulfrx.recipe.navigation.DockDestination
|
||||
import dev.ulfrx.recipe.ui.components.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||
import dev.ulfrx.recipe.ui.screens.shell.dock.DockBar
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.ulfrx.recipe.ui.components.search
|
||||
package dev.ulfrx.recipe.ui.screens.shell.search
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -4,7 +4,8 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data object RecipeGlass {
|
||||
val menu: RecipeGlassStyle = RecipeGlassStyle(
|
||||
val menu: RecipeGlassStyle =
|
||||
RecipeGlassStyle(
|
||||
refraction = 0.10f,
|
||||
curve = 0.5f,
|
||||
edge = 0.04f,
|
||||
@@ -14,7 +15,8 @@ data object RecipeGlass {
|
||||
frost = 15.dp,
|
||||
)
|
||||
|
||||
val dockPress: RecipeGlassStyle = RecipeGlassStyle(
|
||||
val dockPress: RecipeGlassStyle =
|
||||
RecipeGlassStyle(
|
||||
refraction = 0.20f,
|
||||
curve = 0.05f,
|
||||
edge = 0.04f,
|
||||
|
||||
@@ -14,6 +14,7 @@ koin = "4.2.1"
|
||||
koin-plugin = "1.0.0-RC2"
|
||||
kotlin = "2.3.20"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
kotlinx-datetime = "0.6.2"
|
||||
kotlinx-serialization = "1.7.3"
|
||||
ktor = "3.4.2"
|
||||
lokksmith = "0.13.0"
|
||||
@@ -33,6 +34,7 @@ kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", versio
|
||||
|
||||
# kotlinx.serialization (shared DTOs — D-27)
|
||||
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
|
||||
Reference in New Issue
Block a user