refactor(month): make the continuous style a stack of month blocks (#38)

The continuous style streamed weeks with no boundaries at all, which left it
hard to tell where one month ended: the only marker was a "Jul 1" label on the
1st, and every boundary week mixed two months' days into one row.

It is now a vertical stack of self-contained month blocks. Each block shows only
its own days — the boundary week keeps its seven columns so nothing shifts
sideways, but the neighbour month's cells are blank rather than filled with
duplicates of days shown again a block later — under a sticky month header with
whitespace either side. Scrolling stays continuous; only the reading changes.

- The coordinate space moves from absolute week index to absolute month index
  (two LazyColumn items per month: header, then block). Unlike week indices, it
  doesn't depend on the week-start preference, so changing that reflows the rows
  inside a block without moving the block or losing the scroll position.
- The sliding data window now loads months rather than weeks, widened to whole
  grid weeks at both ends so a bar reaching into a block from a clipped-off day
  still renders.
- `clipWeekToMonth` is the pure seam: it drops the neighbour month's pills and
  counts and cuts spanning bars back to the month's own columns, keeping a flat
  cap on the cut side so a bar reads as continuing past the block.
- The top bar carries the year in this style — the block's own header names the
  month, so repeating it two lines up was pure duplication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 17:54:26 +02:00
parent 94a82c2f6c
commit 6e3d10858f
8 changed files with 551 additions and 294 deletions

View File

@@ -23,7 +23,6 @@ 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.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
@@ -49,7 +48,6 @@ 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.snapshotFlow
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -104,12 +102,10 @@ 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.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
@@ -161,24 +157,17 @@ fun MonthScreen(
else -> (state as? MonthUiState.Success)?.today else -> (state as? MonthUiState.Success)?.today
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// Keyed on the week start: week indices are anchored on it, so a changed // Month indices don't move with the week-start preference, so unlike the
// week start would leave the saved scroll offset pointing at a different week. // week-indexed stream this replaced, the list state survives a change to it.
val listState = key(weekStart) { val listState = rememberLazyListState(
rememberLazyListState( initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)),
initialFirstVisibleItemIndex =
weekIndexOf(LocalDate(month.year, month.month, 1), weekStart),
) )
}
// The continuous stream has no single "current" month, so the title takes the // Whichever month's block the top of the viewport is in. Its sticky header
// one the viewport mostly sits in: the midweek day of the row below the top // is the first visible item for all but the last sliver of a block, so this
// edge, which flips over as that month's first full week comes into view. // flips exactly when the header does.
val visibleMonth by remember(weekStart) { val visibleMonth by remember {
derivedStateOf { derivedStateOf { yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) }
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month)
}
} }
val titleMonth = if (continuous) visibleMonth else month val titleMonth = if (continuous) visibleMonth else month
@@ -188,16 +177,23 @@ fun MonthScreen(
if (!continuous) return@LaunchedEffect if (!continuous) return@LaunchedEffect
snapshotFlow { snapshotFlow {
val visible = listState.layoutInfo.visibleItemsInfo val visible = listState.layoutInfo.visibleItemsInfo
(visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0) monthIndexForItem(visible.firstOrNull()?.index ?: 0) to
monthIndexForItem(visible.lastOrNull()?.index ?: 0)
} }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) } .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) }
} }
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
// Drives whether the title carries the year. // The continuous style names the month on the block's own sticky header, so
val currentYear = today.year // the bar carries the year instead of repeating it two lines further down.
val locale = currentLocale()
val topBarTitle = if (continuous) {
titleMonth.year.toString()
} else {
formatMonthTitle(titleMonth, locale, 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) }
@@ -215,7 +211,11 @@ fun MonthScreen(
// scrolls to today's week instead — there is nothing to slide. // scrolls to today's week instead — there is nothing to slide.
val jumpToToday = { val jumpToToday = {
if (continuous) { if (continuous) {
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) } scope.launch {
listState.animateScrollToItem(
itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
)
}
Unit Unit
} else { } else {
slideDir = when (val s = state) { slideDir = when (val s = state) {
@@ -231,7 +231,7 @@ fun MonthScreen(
if (continuous) { if (continuous) {
scope.launch { scope.launch {
listState.animateScrollToItem( listState.animateScrollToItem(
weekIndexOf(LocalDate(target.year, target.month, 1), weekStart), itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))),
) )
} }
} else { } else {
@@ -268,8 +268,7 @@ fun MonthScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MonthTopBar( MonthTopBar(
month = titleMonth, title = topBarTitle,
currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
@@ -409,8 +408,7 @@ private fun ContinuousMonthContent(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MonthTopBar( private fun MonthTopBar(
month: YearMonth, title: String,
currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
@@ -419,11 +417,10 @@ private fun MonthTopBar(
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale()
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = formatMonthTitle(month, locale, currentYear), text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
}, },
@@ -502,6 +499,14 @@ private const val MAX_EVENT_ROWS = 3
*/ */
private val CONTINUOUS_ROW_HEIGHT = 112.dp private val CONTINUOUS_ROW_HEIGHT = 112.dp
/**
* Whitespace below a month block, before the next month's header. Paired with the
* header's own top padding it gives the seam between two months roughly a row's
* worth of air — enough that the blocks read as separate without a rule between
* them.
*/
private val CONTINUOUS_MONTH_GAP = 20.dp
@Composable @Composable
internal fun MonthGrid( internal fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
@@ -534,15 +539,19 @@ internal fun MonthGrid(
} }
/** /**
* The continuous grid (#38): one uninterrupted vertical stream of weeks. * The continuous grid (#38): months stacked into one vertical scroll, each a
* self-contained block under its own sticky header.
* *
* The list is indexed by *absolute week number*, so a row's identity never * A block shows *only its own days* — the boundary week keeps its seven columns
* changes and there is no page seam to scroll across. Weeks the loaded window * so nothing shifts sideways, but the neighbour month's cells are left blank
* hasn't reached yet render as a skeleton and pull the window along behind them. * rather than filled with dimmed duplicates of days shown again a block later.
* The whitespace either side of a header is what separates one month from the
* next; scrolling itself never stops at a page seam.
* *
* Deliberately not a stack of month grids — that would still repeat a boundary * The list is indexed by *absolute month index* (two items per month: header,
* week at the end of one month and the start of the next, which is exactly the * then block), so a block's identity never changes and the week-start preference
* duplication #38 asks to be rid of. * can't move it. Months the loaded window hasn't reached render as a skeleton of
* the right height and pull the window along behind them.
*/ */
@Composable @Composable
internal fun ContinuousMonthGrid( internal fun ContinuousMonthGrid(
@@ -552,30 +561,72 @@ internal fun ContinuousMonthGrid(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize()
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), contentPadding = PaddingValues(bottom = 96.dp),
) { ) {
items(count = weekCount, key = { it }) { index -> repeat(monthCount) { index ->
val week = state.weeksByIndex[index] val month = yearMonthForIndex(index)
if (week == null) { stickyHeader(key = "header-$index", contentType = "month-header") {
ContinuousWeekPlaceholder() ContinuousMonthHeader(
} else { month = month,
MonthWeekRow( currentYear = state.today.year,
week = week, isCurrent = month == todayMonth,
)
}
item(key = "month-$index", contentType = "month-block") {
ContinuousMonthBlock(
month = month,
weeks = state.monthsByIndex[index],
weekStart = state.weekStart,
today = state.today, 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, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
labelMonthOnFirst = true, )
}
}
}
}
/**
* One month's week rows. Sized by [weekRowsInMonth] even before its data has
* loaded, so the skeleton occupies exactly what the real block will and a scroll
* across unloaded months doesn't jump when they arrive.
*/
@Composable
private fun ContinuousMonthBlock(
month: YearMonth,
weeks: List<MonthWeek>?,
weekStart: DayOfWeek,
today: LocalDate,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
) {
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (weeks == null) {
repeat(rowCount) { ContinuousWeekPlaceholder() }
} else {
weeks.forEach { week ->
MonthWeekRow(
week = week,
today = today,
inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed.
blankOutside = true,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT), .height(CONTINUOUS_ROW_HEIGHT),
@@ -585,6 +636,27 @@ internal fun ContinuousMonthGrid(
} }
} }
/**
* The month label that pins above its block. Opaque `surface` so the rows scroll
* under it cleanly, and generous vertical padding — the whitespace around the
* label is what makes each month read as its own section.
*/
@Composable
private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) {
val locale = currentLocale()
Text(
text = formatMonthTitle(month, locale, currentYear),
style = MaterialTheme.typography.titleMedium,
color = if (isCurrent) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp)
.padding(top = 20.dp, bottom = 10.dp),
)
}
/** /**
* The month-changing horizontal swipe, shared by the paged and split styles. * The month-changing horizontal swipe, shared by the paged and split styles.
* Accumulates the drag and commits past a small threshold on release — the grid * Accumulates the drag and commits past a small threshold on release — the grid
@@ -888,7 +960,7 @@ private fun MonthWeekRow(
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
labelMonthOnFirst: Boolean = false, blankOutside: 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
@@ -925,8 +997,13 @@ private fun MonthWeekRow(
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.background( .background(
color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer color = when {
else MaterialTheme.colorScheme.surfaceContainerLow, inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
// A blanked cell draws nothing at all: it is
// the gap that shows where the month starts.
blankOutside -> Color.Transparent
else -> MaterialTheme.colorScheme.surfaceContainerLow
},
shape = CELL_SHAPE, shape = CELL_SHAPE,
), ),
) )
@@ -936,22 +1013,18 @@ private fun MonthWeekRow(
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
week.days.forEach { d -> week.days.forEach { d ->
if (blankOutside && !inMonth(d)) {
Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT))
} else {
DayNumberCell( DayNumberCell(
date = d, date = d,
isToday = d == today, isToday = d == today,
inMonth = inMonth(d), 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),
) )
} }
} }
}
// Breathing room between the day number (and today's circle) and the // Breathing room between the day number (and today's circle) and the
// first event row. // first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP)) Spacer(Modifier.height(DAY_NUMBER_GAP))
@@ -1030,9 +1103,13 @@ private fun MonthWeekRow(
} }
// Tap layer: in month view a tap on any day opens that day. Padded and // Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it. // clipped to the background pill so the ripple matches it. A blanked
// cell isn't part of this month, so it takes no taps either.
Row(Modifier.matchParentSize()) { Row(Modifier.matchParentSize()) {
week.days.forEach { d -> week.days.forEach { d ->
if (blankOutside && !inMonth(d)) {
Spacer(Modifier.weight(1f).fillMaxHeight())
} else {
Box( Box(
Modifier Modifier
.weight(1f) .weight(1f)
@@ -1046,6 +1123,7 @@ private fun MonthWeekRow(
} }
} }
} }
}
/** /**
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
@@ -1080,7 +1158,6 @@ 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),
@@ -1100,16 +1177,6 @@ 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(),
@@ -1121,16 +1188,6 @@ 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

@@ -112,8 +112,9 @@ internal fun MonthStylePreview(
MonthViewStyle.Continuous -> ContinuousMonthGrid( MonthViewStyle.Continuous -> ContinuousMonthGrid(
state = sample.continuous, state = sample.continuous,
listState = rememberLazyListState( listState = rememberLazyListState(
initialFirstVisibleItemIndex = initialFirstVisibleItemIndex = itemIndexForMonth(
weekIndexOf(LocalDate(today.year, today.month, 1), weekStart), monthIndexOf(YearMonth(today.year, today.month)),
),
), ),
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
@@ -157,7 +158,7 @@ private class SampleMonth(
/** /**
* A month's worth of stand-in events, laid out through the same * A month's worth of stand-in events, laid out through the same
* [layoutMonthWeeks] / [layoutCalendarWeek] the live views use — so the preview * [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
* exercises the real span, lane and overflow logic rather than approximating it. * exercises the real span, lane and overflow logic rather than approximating it.
* *
* Colours are raw ARGB on purpose: that is what the provider hands out for an * Colours are raw ARGB on purpose: that is what the provider hands out for an
@@ -181,17 +182,15 @@ private fun sampleMonthState(
zone = zone, zone = zone,
) )
// Enough weeks either side of today for the continuous list to fill the // This month and the next: enough for the preview to show a month block, the
// viewport and still have somewhere to scroll. // whitespace under it and the following month's header coming up.
val centre = weekIndexOf(today, weekStart) val centre = monthIndexOf(ym)
val window = (centre - 8)..(centre + 8) val window = centre..(centre + 1)
val continuous = ContinuousMonthUiState.Success( val continuous = ContinuousMonthUiState.Success(
today = today, today = today,
weeksByIndex = window.associateWith { index -> monthsByIndex = window.associateWith { index ->
val days = (0 until 7).map { val m = yearMonthForIndex(index)
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
}
layoutCalendarWeek(days, events, zone)
}, },
weekStart = weekStart, weekStart = weekStart,
) )

View File

@@ -40,18 +40,21 @@ data class MonthWeek(
) )
/** /**
* State for the continuous style (#38). Weeks are keyed by absolute week index * State for the continuous style (#38): a vertical stream of *self-contained*
* rather than gathered into months: the whole point of the style is that there * months rather than one undifferentiated run of weeks. Each month is keyed by
* are no month boundaries to scroll across, so there is no "current month" here * absolute month index and carries only its own days — the boundary week keeps
* and no notion of an out-of-month day. [weeksByIndex] holds only the loaded * its seven columns so the grid geometry never shifts, but the neighbour month's
* window; indices outside it render as placeholders until the window catches up. * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
*
* [monthsByIndex] holds only the loaded window; indices outside it render as
* placeholders until the window catches up.
*/ */
sealed interface ContinuousMonthUiState { sealed interface ContinuousMonthUiState {
data object Loading : ContinuousMonthUiState data object Loading : ContinuousMonthUiState
data class Failure(val reason: FailureReason) : ContinuousMonthUiState data class Failure(val reason: FailureReason) : ContinuousMonthUiState
data class Success( data class Success(
val today: LocalDate, val today: LocalDate,
val weeksByIndex: Map<Int, MonthWeek>, val monthsByIndex: Map<Int, List<MonthWeek>>,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
) : ContinuousMonthUiState ) : ContinuousMonthUiState
} }

View File

@@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone 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
@@ -102,24 +102,30 @@ class MonthViewModel @Inject constructor(
// --- Continuous style (#38) ------------------------------------------- // --- Continuous style (#38) -------------------------------------------
// //
// The continuous grid is one endless stream of weeks, so it can't load "a // The continuous grid scrolls through every month there is, so it can't load
// month" — it loads a sliding window of week indices around whatever is on // one month at a time — it loads a sliding window of month indices around
// screen. The window only moves when the visible range comes within // whatever is on screen. The window only moves when the visible range comes
// WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally // within WINDOW_EDGE months of a loaded edge, so a scroll re-queries
// rather than on every frame. // occasionally rather than on every frame.
// Seeded around today so the first frame has data. The week-start preference // Seeded around today so the first frame has data.
// hasn't arrived yet, but anchoring on a different day shifts an index by at private val _loadedMonths = MutableStateFlow(
// most one week — well inside the pad, and the first scroll report corrects it. monthIndexOf(YearMonth(todayDate.year, todayDate.month))
private val _loadedWeeks = MutableStateFlow( .let { it - WINDOW_PAD..it + WINDOW_PAD },
weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD },
) )
val continuousState: StateFlow<ContinuousMonthUiState> = val continuousState: StateFlow<ContinuousMonthUiState> =
combine(_loadedWeeks, weekStart) { window, ws -> window to ws } combine(_loadedMonths, weekStart) { window, ws -> window to ws }
.flatMapLatest { (window, ws) -> .flatMapLatest { (window, ws) ->
val first = weekStartForIndex(window.first, ws) // Widened to whole grid weeks at both ends: a month block still
val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY) // has to know about an event that starts in the boundary week's
// clipped-off days, or a bar running into the block would vanish.
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
val last = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
.startOfGridWeek(ws)
.plus(6, DateTimeUnit.DAY)
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
combine( combine(
repository.calendars(), repository.calendars(),
@@ -137,12 +143,12 @@ class MonthViewModel @Inject constructor(
) )
/** /**
* Report which absolute week indices are on screen. Cheap to call on every * Report which absolute month indices are on screen. Cheap to call on every
* scroll frame: it returns immediately unless the visible range has drifted * scroll frame: it returns immediately unless the visible range has drifted
* close enough to a loaded edge to warrant a wider query. * close enough to a loaded edge to warrant a wider query.
*/ */
fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) { fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it } nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it }
} }
private fun buildContinuousState( private fun buildContinuousState(
@@ -154,15 +160,13 @@ class MonthViewModel @Inject constructor(
if (calendars.isEmpty()) { if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val weeks = window.associateWith { index -> val months = window.associateWith { index ->
val days = (0 until 7).map { val ym = yearMonthForIndex(index)
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) }
}
layoutCalendarWeek(days, instances, zone)
} }
return ContinuousMonthUiState.Success( return ContinuousMonthUiState.Success(
today = todayDate, today = todayDate,
weeksByIndex = weeks, monthsByIndex = months,
weekStart = weekStart, weekStart = weekStart,
) )
} }
@@ -252,12 +256,8 @@ internal fun layoutMonthWeeks(
instances: List<EventInstance>, instances: List<EventInstance>,
zone: TimeZone, zone: TimeZone,
): List<MonthWeek> { ): List<MonthWeek> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1) val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
val gridStart = firstOfMonth.startOfGridWeek(weekStart) val weekCount = weekRowsInMonth(ym, weekStart)
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
val weekCount = (leadOffset + daysInMonth + 6) / 7
return (0 until weekCount).map { row -> return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
@@ -336,13 +336,13 @@ internal fun monthGridRange(
} }
/** /**
* How many weeks the continuous window loads beyond the visible range, and how * How many months 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 * 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 * is generous relative to the trigger so a steady scroll crosses the trigger
* well before it would run out of laid-out weeks. * well before it would run out of laid-out months.
*/ */
private const val WINDOW_PAD = 12 private const val WINDOW_PAD = 4
private const val WINDOW_EDGE = 4 private const val WINDOW_EDGE = 2
/** /**
* Which day the split style should select when the grid lands on [month]: * Which day the split style should select when the grid lands on [month]:
@@ -370,28 +370,89 @@ internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: In
} }
/** /**
* The continuous grid addresses weeks by an absolute index rather than a * The continuous grid addresses months by an absolute index, so the list has one
* (month, row) pair, so the list has one stable, gap-free coordinate space to * stable, gap-free coordinate space to scroll through and key its items by.
* scroll through and key its items by. Index 0 is the first week of 1900 under * Index 0 is January 1900; the list runs to [continuousMonthCount].
* 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, * Unlike the week indexing this replaced, the coordinate space doesn't depend on
* which keeps the LazyColumn's item indices and week indices the same number. * the week-start preference — changing it reflows the rows *inside* a month
* block but never moves the block, so a scroll position stays put.
*/ */
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) private const val MONTH_INDEX_EPOCH_YEAR = 1900
private val WEEK_INDEX_END = LocalDate(2100, 12, 31) private const val MONTH_INDEX_END_YEAR = 2100
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { internal fun monthIndexOf(ym: YearMonth): Int =
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
MONTH_INDEX_EPOCH_YEAR + index / 12,
Month.entries[index % 12],
)
/** Total months the continuous grid scrolls through (1900 → 2100). */
internal fun continuousMonthCount(): Int =
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
/**
* Each month occupies two LazyColumn items — its sticky header and the block of
* week rows under it — so the two coordinate spaces differ by a factor of two.
* Scrolling to a month means scrolling to its header.
*/
internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2
internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
val first = firstOfMonth(ym)
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
return (leadOffset + daysInMonth + 6) / 7
} }
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = /**
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) * Strip everything outside [month] from a boundary week row, for the continuous
* style's self-contained month blocks.
/** Total weeks the continuous grid scrolls through (1900 → 2100). */ *
internal fun continuousWeekCount(weekStart: DayOfWeek): Int = * The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
weekIndexOf(WEEK_INDEX_END, weekStart) + 1 * the grid decides which cells to draw blank — but the neighbour month's events
* are removed: its pills and counts go, and a bar reaching in from (or out into)
* it is cut back to the month's own columns. A bar cut this way keeps a flat
* cap, which is what says "this continues past the block" rather than "it ends
* here".
*/
internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
val inMonth = week.days.map { it.year == month.year && it.month == month.month }
val firstCol = inMonth.indexOfFirst { it }
val lastCol = inMonth.indexOfLast { it }
// Can't happen for a row of the month's own grid, but a caller-proof no-op
// beats an out-of-range crash.
if (firstCol == -1) {
return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap())
}
val spans = week.spans.mapNotNull { span ->
val start = maxOf(span.startCol, firstCol)
val end = minOf(span.endCol, lastCol)
if (start > end) {
null
} else {
span.copy(
startCol = start,
endCol = end,
continuesLeft = span.continuesLeft || start > span.startCol,
continuesRight = span.continuesRight || end < span.endCol,
)
}
}
val own = week.days.filterIndexed { col, _ -> inMonth[col] }
return week.copy(
spans = spans,
timedByDay = week.timedByDay.filterKeys { it in own },
countByDay = week.countByDay.filterKeys { it in own },
)
}
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.

View File

@@ -379,7 +379,7 @@
<string name="month_style_paged">Pages</string> <string name="month_style_paged">Pages</string>
<string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string> <string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string>
<string name="month_style_continuous">Continuous</string> <string name="month_style_continuous">Continuous</string>
<string name="month_style_continuous_summary">Scroll up and down through the weeks. No month is cut off and no day appears twice.</string> <string name="month_style_continuous_summary">Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours.</string>
<string name="month_style_split">Split</string> <string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string> <string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string> <string name="month_split_no_events">Nothing scheduled</string>

View File

@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What makes the continuous style's month blocks self-contained: a boundary week
* keeps its seven columns but carries only the block's own month.
*/
class ClipWeekToMonthTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 Jul 5. */
private fun firstRowOfJuly(events: List<EventInstance>) =
clipWeekToMonth(
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(),
jul26,
)
@Test
fun `the row keeps all seven days so the columns don't shift`() {
val week = firstRowOfJuly(emptyList())
assertThat(week.days).hasSize(7)
assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29))
}
@Test
fun `the neighbour month's events are dropped`() {
val june = timed(LocalDate(2026, 6, 30), 9)
val july = timed(LocalDate(2026, 7, 1), 9, id = 3L)
val week = firstRowOfJuly(listOf(june, july))
assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30))
assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july)
assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull()
assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1)
}
@Test
fun `a bar reaching in from the previous month is cut back to the 1st`() {
// Jun 29 Jul 2: columns 0..3 unclipped, 2..3 once July owns the row.
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2))))
val span = week.spans.single()
assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st
assertThat(span.endCol).isEqualTo(3)
// Flat cap on the cut side: the event continues out of this block.
assertThat(span.continuesLeft).isTrue()
assertThat(span.continuesRight).isFalse()
}
@Test
fun `a bar living entirely in the neighbour month disappears`() {
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30))))
assertThat(week.spans).isEmpty()
}
@Test
fun `a bar reaching out into the next month is cut at the last day`() {
// Jul 29 Aug 2 sits in July's final row (Jul 27 Aug 2).
val weeks = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))),
zone,
)
val span = clipWeekToMonth(weeks.last(), jul26).spans.single()
assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th
assertThat(span.endCol).isEqualTo(4) // Friday the 31st
assertThat(span.continuesRight).isTrue()
assertThat(span.continuesLeft).isFalse()
}
@Test
fun `a row wholly inside the month is untouched`() {
val mid = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))),
zone,
)[1]
assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid)
}
}

View File

@@ -0,0 +1,138 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import org.junit.jupiter.api.Test
/**
* The continuous grid's coordinate space. Every block's identity — and the
* LazyColumn items it maps to — hangs off this arithmetic, so it gets its own
* tests rather than being exercised only through the UI.
*/
class ContinuousMonthIndexTest {
private val jun26 = YearMonth(2026, Month.JUNE)
@Test
fun `consecutive months are consecutive indices`() {
val jun = monthIndexOf(jun26)
assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1)
assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1)
// And across the year boundary.
assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY)))
.isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1)
}
@Test
fun `index round-trips back to its month`() {
listOf(
YearMonth(1900, Month.JANUARY),
jun26,
YearMonth(2026, Month.DECEMBER),
YearMonth(2100, Month.DECEMBER),
).forEach { ym ->
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
}
}
@Test
fun `indices are non-negative and span 1900 through 2100`() {
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
.isEqualTo(continuousMonthCount() - 1)
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
}
@Test
fun `a month maps to its header item and back`() {
val index = monthIndexOf(jun26)
val item = itemIndexForMonth(index)
// Both of a month's items — its sticky header and the block under it —
// resolve back to the same month, so a title read off the first visible
// item is right wherever the viewport sits inside the block.
assertThat(monthIndexForItem(item)).isEqualTo(index)
assertThat(monthIndexForItem(item + 1)).isEqualTo(index)
assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1)
}
@Test
fun `week rows follow the month's shape and its week start`() {
// June 2026 starts on a Monday: 30 days → 5 rows either way.
assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5)
assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5)
// February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4
// rows, a Monday-anchored one needs 5 — the block's height moves with the
// preference even though its position in the list doesn't.
val feb26 = YearMonth(2026, Month.FEBRUARY)
assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4)
assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5)
// February 2021 starts on a Monday and is 28 days → exactly 4.
assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY))
.isEqualTo(4)
}
@Test
fun `the placeholder block is the size the loaded one will be`() {
// Otherwise scrolling across an unloaded month jumps when its data lands.
(0 until 24).forEach { offset ->
val ym = yearMonthForIndex(monthIndexOf(jun26) + offset)
DayOfWeek.entries.forEach { ws ->
assertThat(weekRowsInMonth(ym, ws))
.isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size)
}
}
}
@Test
fun `firstOfMonth is the 1st`() {
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
}
@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 a month of the top edge → reload, padded on both sides.
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
.isEqualTo(-3..6)
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
.isEqualTo(94..104)
}
@Test
fun `a jump far outside the window reloads around the destination`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
.isEqualTo(496..505)
}
@Test
fun `the reloaded window always clears the trigger it just crossed`() {
// Otherwise every scroll frame would re-trigger a query.
val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!!
assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull()
}
@Test
fun `the split selection follows the month, landing on today when it's there`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
}
@Test
fun `the split selection falls to the 1st of any other month`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
}

View File

@@ -1,121 +0,0 @@
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.YearMonth
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()
}
@Test
fun `the split selection follows the month, landing on today when it's there`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today))
.isEqualTo(today)
}
@Test
fun `the split selection falls to the 1st of any other month`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
}