feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
4 changed files with 485 additions and 35 deletions
Showing only changes of commit 542744e342 - Show all commits

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -19,6 +20,10 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -36,9 +41,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -85,11 +93,14 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
@@ -112,8 +123,10 @@ fun MonthScreen(
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming // The instant before which an event counts as completed, or null when dimming
@@ -128,17 +141,52 @@ fun MonthScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isOnCurrentMonth = when (val s = state) { val continuous = viewStyle == MonthViewStyle.Continuous
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
else -> true // Today, from whichever state is driving; the clock only covers the first
// frame, before either has loaded.
val today = when {
continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today
else -> (state as? MonthUiState.Success)?.today
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// Keyed on the week start: week indices are anchored on it, so a changed
// week start would leave the saved scroll offset pointing at a different week.
val listState = key(weekStart) {
rememberLazyListState(
initialFirstVisibleItemIndex =
weekIndexOf(LocalDate(month.year, month.month, 1), weekStart),
)
} }
// Drives whether the title carries the year. Falls back to the clock only // The continuous stream has no single "current" month, so the title takes the
// while the first load is in flight, when there is no state to read today from. // one the viewport mostly sits in: the midweek day of the row below the top
val currentYear = when (val s = state) { // edge, which flips over as that month's first full week comes into view.
is MonthUiState.Success -> s.today.year val visibleMonth by remember(weekStart) {
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year derivedStateOf {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month)
} }
}
val titleMonth = if (continuous) visibleMonth else month
// Feed the visible range back so the sliding data window can follow. The
// view model ignores everything that doesn't near a loaded edge.
LaunchedEffect(listState, continuous) {
if (!continuous) return@LaunchedEffect
snapshotFlow {
val visible = listState.layoutInfo.visibleItemsInfo
(visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0)
}
.distinctUntilChanged()
.collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) }
}
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
// Drives whether the title carries the year.
val currentYear = today.year
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
@@ -152,8 +200,13 @@ fun MonthScreen(
viewModel.goToPrev() viewModel.goToPrev()
} }
// Slide toward today: viewing the future → today comes in from the left // Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward). // (back), viewing the past → from the right (forward). The continuous stream
// scrolls to today's week instead — there is nothing to slide.
val jumpToToday = { val jumpToToday = {
if (continuous) {
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) }
Unit
} else {
slideDir = when (val s = state) { slideDir = when (val s = state) {
is MonthUiState.Success -> is MonthUiState.Success ->
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
@@ -161,11 +214,20 @@ fun MonthScreen(
} }
viewModel.goToToday() viewModel.goToToday()
} }
}
// Drawer jump-to-date: slide from the side the target month lies on. // Drawer jump-to-date: slide from the side the target month lies on.
val jumpToDate: (LocalDate) -> Unit = { target -> val jumpToDate: (LocalDate) -> Unit = { target ->
if (continuous) {
scope.launch {
listState.animateScrollToItem(
weekIndexOf(LocalDate(target.year, target.month, 1), weekStart),
)
}
} else {
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
viewModel.goToDate(target) viewModel.goToDate(target)
} }
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -174,7 +236,7 @@ fun MonthScreen(
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = LocalDate(month.year, month.month, 1), currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
viewOrder = drawerViewOrder, viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
@@ -195,7 +257,7 @@ fun MonthScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MonthTopBar( MonthTopBar(
month = month, month = titleMonth,
currentYear = currentYear, currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
@@ -213,11 +275,9 @@ fun MonthScreen(
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { onCreate = {
// Anchor on today when its month is shown, else the 1st. // Anchor on today when its month is shown, else the 1st.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent( onCreateEvent(
if (isOnCurrentMonth) today if (isOnCurrentMonth) today
else LocalDate(month.year, month.month, 1), else LocalDate(titleMonth.year, titleMonth.month, 1),
null, null,
) )
}, },
@@ -231,6 +291,15 @@ fun MonthScreen(
) { ) {
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
if (continuous) {
ContinuousMonthContent(
state = continuousState,
listState = listState,
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
} else {
MonthContent( MonthContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
@@ -244,6 +313,7 @@ fun MonthScreen(
} }
} }
} }
}
} }
@Composable @Composable
@@ -303,6 +373,32 @@ private fun MonthContent(
} }
} }
/**
* Continuous style content. No swipe detector and no [AnimatedContent]: vertical
* scrolling owns the gesture, and the list never swaps wholesale — it keeps
* scrolling while the window behind it reloads.
*/
@Composable
private fun ContinuousMonthContent(
state: ContinuousMonthUiState,
listState: LazyListState,
showWeekNumbers: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
) {
when (state) {
ContinuousMonthUiState.Loading -> MonthGridLoading()
is ContinuousMonthUiState.Failure ->
CalendarFailure(reason = state.reason, onRetry = onRetry)
is ContinuousMonthUiState.Success -> ContinuousMonthGrid(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MonthTopBar( private fun MonthTopBar(
@@ -391,6 +487,14 @@ private val CELL_GAP = 2.dp
private val CELL_SHAPE = RoundedCornerShape(12.dp) private val CELL_SHAPE = RoundedCornerShape(12.dp)
private const val MAX_EVENT_ROWS = 3 private const val MAX_EVENT_ROWS = 3
/**
* Row height in the continuous grid. The paged grid divides the viewport between
* however many rows the month has; a scrolling stream has no such bound, so it
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
* close to what a five-row month gets on a typical phone.
*/
private val CONTINUOUS_ROW_HEIGHT = 112.dp
@Composable @Composable
private fun MonthGrid( private fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
@@ -406,11 +510,12 @@ private fun MonthGrid(
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
val month = state.month
state.weeks.forEach { week -> state.weeks.forEach { week ->
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = state.today, today = state.today,
month = state.month, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
modifier = Modifier modifier = Modifier
@@ -421,6 +526,78 @@ private fun MonthGrid(
} }
} }
/**
* The continuous grid (#38): one uninterrupted vertical stream of weeks.
*
* The list is indexed by *absolute week number*, so a row's identity never
* changes and there is no page seam to scroll across. Weeks the loaded window
* hasn't reached yet render as a skeleton and pull the window along behind them.
*
* Deliberately not a stack of month grids — that would still repeat a boundary
* week at the end of one month and the start of the next, which is exactly the
* duplication #38 asks to be rid of.
*/
@Composable
private fun ContinuousMonthGrid(
state: ContinuousMonthUiState.Success,
listState: LazyListState,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
) {
items(count = weekCount, key = { it }) { index ->
val week = state.weeksByIndex[index]
if (week == null) {
ContinuousWeekPlaceholder()
} else {
MonthWeekRow(
week = week,
today = state.today,
// Every day in the stream belongs to a month equally — there
// is no "other month" to recede here.
inMonth = { true },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
labelMonthOnFirst = true,
modifier = Modifier
.fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT),
)
}
}
}
}
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
@Composable
private fun ContinuousWeekPlaceholder() {
Row(
modifier = Modifier
.fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT),
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE),
)
}
}
}
/** /**
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
* a multi-day event is one connected bar across the columns; single-day timed * a multi-day event is one connected bar across the columns; single-day timed
@@ -432,10 +609,11 @@ private fun MonthGrid(
private fun MonthWeekRow( private fun MonthWeekRow(
week: MonthWeek, week: MonthWeek,
today: LocalDate, today: LocalDate,
month: YearMonth, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
labelMonthOnFirst: Boolean = false,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -466,14 +644,13 @@ private fun MonthWeekRow(
// as one continuous event. // as one continuous event.
Row(Modifier.matchParentSize()) { Row(Modifier.matchParentSize()) {
week.days.forEach { d -> week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box( Box(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.background( .background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow, else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE, shape = CELL_SHAPE,
), ),
@@ -487,7 +664,15 @@ private fun MonthWeekRow(
DayNumberCell( DayNumberCell(
date = d, date = d,
isToday = d == today, isToday = d == today,
inMonth = d.month == month.month && d.year == month.year, inMonth = inMonth(d),
// Continuous has no month boundaries to dim across, so
// the 1st names its month instead — the only marker
// telling one month from the next inside the stream.
monthLabel = if (labelMonthOnFirst && d.day == 1) {
shortMonthName(d)
} else {
null
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -620,6 +805,7 @@ private fun DayNumberCell(
isToday: Boolean, isToday: Boolean,
inMonth: Boolean, inMonth: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
monthLabel: String? = null,
) { ) {
Box( Box(
modifier = modifier.height(DAY_NUMBER_HEIGHT), modifier = modifier.height(DAY_NUMBER_HEIGHT),
@@ -639,6 +825,16 @@ private fun DayNumberCell(
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
) )
} }
} else if (monthLabel != null) {
Text(
text = "$monthLabel ${date.day}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Visible,
softWrap = false,
)
} else { } else {
Text( Text(
text = date.day.toString(), text = date.day.toString(),
@@ -650,6 +846,16 @@ private fun DayNumberCell(
} }
} }
/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous grid. */
@Composable
private fun shortMonthName(date: LocalDate): String {
val locale = currentLocale()
return remember(date.month, locale) {
java.time.Month.of(date.month.ordinal + 1)
.getDisplayName(JavaTextStyle.SHORT, locale)
}
}
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
@Composable @Composable
private fun MonthBar( private fun MonthBar(

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
@@ -37,6 +38,23 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>, val countByDay: Map<LocalDate, Int>,
) )
/**
* State for the continuous style (#38). Weeks are keyed by absolute week index
* rather than gathered into months: the whole point of the style is that there
* are no month boundaries to scroll across, so there is no "current month" here
* and no notion of an out-of-month day. [weeksByIndex] holds only the loaded
* window; indices outside it render as placeholders until the window catches up.
*/
sealed interface ContinuousMonthUiState {
data object Loading : ContinuousMonthUiState
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
data class Success(
val today: LocalDate,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek,
) : ContinuousMonthUiState
}
sealed interface MonthUiState { sealed interface MonthUiState {
data object Loading : MonthUiState data object Loading : MonthUiState
data class Failure(val reason: FailureReason) : MonthUiState data class Failure(val reason: FailureReason) : MonthUiState

View File

@@ -29,6 +29,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
@@ -66,6 +67,14 @@ class MonthViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
/** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthViewStyle.Paged,
)
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = Clock.System.now().toLocalDateTime(zone).date
@@ -91,6 +100,73 @@ class MonthViewModel @Inject constructor(
initialValue = MonthUiState.Loading, initialValue = MonthUiState.Loading,
) )
// --- Continuous style (#38) -------------------------------------------
//
// The continuous grid is one endless stream of weeks, so it can't load "a
// month" — it loads a sliding window of week indices around whatever is on
// screen. The window only moves when the visible range comes within
// WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally
// rather than on every frame.
// Seeded around today so the first frame has data. The week-start preference
// hasn't arrived yet, but anchoring on a different day shifts an index by at
// most one week — well inside the pad, and the first scroll report corrects it.
private val _loadedWeeks = MutableStateFlow(
weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD },
)
val continuousState: StateFlow<ContinuousMonthUiState> =
combine(_loadedWeeks, weekStart) { window, ws -> window to ws }
.flatMapLatest { (window, ws) ->
val first = weekStartForIndex(window.first, ws)
val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY)
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildContinuousState(window, ws, calendars, instances)
}
}
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = ContinuousMonthUiState.Loading,
)
/**
* Report which absolute week indices are on screen. Cheap to call on every
* scroll frame: it returns immediately unless the visible range has drifted
* close enough to a loaded edge to warrant a wider query.
*/
fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) {
nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it }
}
private fun buildContinuousState(
window: IntRange,
weekStart: DayOfWeek,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): ContinuousMonthUiState {
if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val weeks = window.associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, instances, zone)
}
return ContinuousMonthUiState.Success(
today = todayDate,
weeksByIndex = weeks,
weekStart = weekStart,
)
}
fun goToPrev() { fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH) _month.value = _month.value.minus(1, DateTimeUnit.MONTH)
} }
@@ -225,6 +301,52 @@ internal fun monthGridRange(
return start..end return start..end
} }
/**
* How many weeks the continuous window loads beyond the visible range, and how
* close the visible range may drift to a loaded edge before it reloads. The pad
* is generous relative to the trigger so a steady scroll crosses the trigger
* well before it would run out of laid-out weeks.
*/
private const val WINDOW_PAD = 12
private const val WINDOW_EDGE = 4
/**
* The window to load for a visible range, or null to keep the current one.
*
* Kept pure and separate from the view model so the hysteresis — the reason a
* scroll doesn't re-query the provider on every frame — is testable on its own.
*/
internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? {
val comfortablyInside =
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
if (comfortablyInside) return null
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
}
/**
* The continuous grid addresses weeks by an absolute index rather than a
* (month, row) pair, so the list has one stable, gap-free coordinate space to
* scroll through and key its items by. Index 0 is the first week of 1900 under
* the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT].
*
* The epoch is deliberately far in the past so every index is non-negative,
* which keeps the LazyColumn's item indices and week indices the same number.
*/
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
}
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the continuous grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate { internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7

View File

@@ -0,0 +1,104 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
/**
* The continuous grid's coordinate space. Every row's identity — and the
* LazyColumn item it maps to — hangs off this arithmetic, so it gets its own
* tests rather than being exercised only through the UI.
*/
class ContinuousWeekIndexTest {
// 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week.
private val mon = LocalDate(2026, 6, 8)
private val wed = LocalDate(2026, 6, 10)
@Test
fun `every day of a week shares one index`() {
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(indices.toSet()).hasSize(1)
}
@Test
fun `consecutive weeks are consecutive indices`() {
val a = weekIndexOf(mon, DayOfWeek.MONDAY)
val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
assertThat(b).isEqualTo(a + 1)
assertThat(back).isEqualTo(a - 1)
}
@Test
fun `index round-trips back to the week's first day`() {
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
}
}
@Test
fun `the week start shifts which week a boundary day belongs to`() {
// Sunday the 14th closes the Monday-anchored week but opens the Sunday one.
val sun = LocalDate(2026, 6, 14)
assertThat(weekIndexOf(sun, DayOfWeek.MONDAY))
.isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY))
assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY))
.isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY))
}
@Test
fun `indices are non-negative across the supported span`() {
// The epoch sits before any date the grid scrolls to, so item indices and
// week indices stay the same number — no offset to reconcile.
DayOfWeek.entries.forEach { ws ->
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0)
}
}
@Test
fun `the list spans 1900 through 2100`() {
DayOfWeek.entries.forEach { ws ->
val count = continuousWeekCount(ws)
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count)
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count)
// ~200 years of weeks, give or take the anchor.
assertThat(count).isIn(10_400..10_500)
}
}
@Test
fun `a window comfortably around the visible range is kept`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
}
@Test
fun `nearing a loaded edge widens the window around the visible range`() {
// Within four weeks of the top edge → reload, padded on both sides.
val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7)
assertThat(widened).isEqualTo(-10..19)
val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99)
assertThat(atBottom).isEqualTo(82..111)
}
@Test
fun `a jump far outside the window reloads around the destination`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505))
.isEqualTo(488..517)
}
@Test
fun `the reloaded window always clears the trigger it just crossed`() {
// Otherwise every scroll frame would re-trigger a query.
var loaded = 0..100
val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!!
loaded = window
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
}
}