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:
@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.size
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -104,12 +102,10 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
@@ -161,24 +157,17 @@ fun MonthScreen(
|
||||
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),
|
||||
// Month indices don't move with the week-start preference, so unlike the
|
||||
// week-indexed stream this replaced, the list state survives a change to it.
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)),
|
||||
)
|
||||
}
|
||||
|
||||
// The continuous stream has no single "current" month, so the title takes the
|
||||
// one the viewport mostly sits in: the midweek day of the row below the top
|
||||
// edge, which flips over as that month's first full week comes into view.
|
||||
val visibleMonth by remember(weekStart) {
|
||||
derivedStateOf {
|
||||
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
|
||||
.plus(3, DateTimeUnit.DAY)
|
||||
YearMonth(midweek.year, midweek.month)
|
||||
}
|
||||
// Whichever month's block the top of the viewport is in. Its sticky header
|
||||
// is the first visible item for all but the last sliver of a block, so this
|
||||
// flips exactly when the header does.
|
||||
val visibleMonth by remember {
|
||||
derivedStateOf { yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) }
|
||||
}
|
||||
val titleMonth = if (continuous) visibleMonth else month
|
||||
|
||||
@@ -188,16 +177,23 @@ fun MonthScreen(
|
||||
if (!continuous) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
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()
|
||||
.collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) }
|
||||
.collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) }
|
||||
}
|
||||
|
||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
||||
|
||||
// Drives whether the title carries the year.
|
||||
val currentYear = today.year
|
||||
// The continuous style names the month on the block's own sticky header, so
|
||||
// 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).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
@@ -215,7 +211,11 @@ fun MonthScreen(
|
||||
// scrolls to today's week instead — there is nothing to slide.
|
||||
val jumpToToday = {
|
||||
if (continuous) {
|
||||
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) }
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
|
||||
)
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
slideDir = when (val s = state) {
|
||||
@@ -231,7 +231,7 @@ fun MonthScreen(
|
||||
if (continuous) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
weekIndexOf(LocalDate(target.year, target.month, 1), weekStart),
|
||||
itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -268,8 +268,7 @@ fun MonthScreen(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
month = titleMonth,
|
||||
currentYear = currentYear,
|
||||
title = topBarTitle,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
@@ -409,8 +408,7 @@ private fun ContinuousMonthContent(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
month: YearMonth,
|
||||
currentYear: Int,
|
||||
title: String,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
@@ -419,11 +417,10 @@ private fun MonthTopBar(
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatMonthTitle(month, locale, currentYear),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -502,6 +499,14 @@ private const val MAX_EVENT_ROWS = 3
|
||||
*/
|
||||
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
|
||||
internal fun MonthGrid(
|
||||
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
|
||||
* 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.
|
||||
* A block shows *only its own days* — the boundary week keeps its seven columns
|
||||
* so nothing shifts sideways, but the neighbour month's cells are left blank
|
||||
* 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
|
||||
* week at the end of one month and the start of the next, which is exactly the
|
||||
* duplication #38 asks to be rid of.
|
||||
* The list is indexed by *absolute month index* (two items per month: header,
|
||||
* then block), so a block's identity never changes and the week-start preference
|
||||
* 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
|
||||
internal fun ContinuousMonthGrid(
|
||||
@@ -552,30 +561,72 @@ internal fun ContinuousMonthGrid(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
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(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
// 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 ->
|
||||
val week = state.weeksByIndex[index]
|
||||
if (week == null) {
|
||||
ContinuousWeekPlaceholder()
|
||||
} else {
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
repeat(monthCount) { index ->
|
||||
val month = yearMonthForIndex(index)
|
||||
stickyHeader(key = "header-$index", contentType = "month-header") {
|
||||
ContinuousMonthHeader(
|
||||
month = month,
|
||||
currentYear = state.today.year,
|
||||
isCurrent = month == todayMonth,
|
||||
)
|
||||
}
|
||||
item(key = "month-$index", contentType = "month-block") {
|
||||
ContinuousMonthBlock(
|
||||
month = month,
|
||||
weeks = state.monthsByIndex[index],
|
||||
weekStart = state.weekStart,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
.fillMaxWidth()
|
||||
.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.
|
||||
* Accumulates the drag and commits past a small threshold on release — the grid
|
||||
@@ -888,7 +960,7 @@ private fun MonthWeekRow(
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
labelMonthOnFirst: Boolean = false,
|
||||
blankOutside: Boolean = false,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
@@ -925,8 +997,13 @@ private fun MonthWeekRow(
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(
|
||||
color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
color = when {
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -936,22 +1013,18 @@ private fun MonthWeekRow(
|
||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.days.forEach { d ->
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT))
|
||||
} else {
|
||||
DayNumberCell(
|
||||
date = d,
|
||||
isToday = d == today,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Breathing room between the day number (and today's circle) and the
|
||||
// first event row.
|
||||
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
|
||||
// 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()) {
|
||||
week.days.forEach { d ->
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Spacer(Modifier.weight(1f).fillMaxHeight())
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -1046,6 +1123,7 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||
@@ -1080,7 +1158,6 @@ private fun DayNumberCell(
|
||||
isToday: Boolean,
|
||||
inMonth: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
monthLabel: String? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.height(DAY_NUMBER_HEIGHT),
|
||||
@@ -1100,16 +1177,6 @@ private fun DayNumberCell(
|
||||
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 {
|
||||
Text(
|
||||
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. */
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
|
||||
@@ -112,8 +112,9 @@ internal fun MonthStylePreview(
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(LocalDate(today.year, today.month, 1), weekStart),
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
@@ -157,7 +158,7 @@ private class SampleMonth(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
@@ -181,17 +182,15 @@ private fun sampleMonthState(
|
||||
zone = zone,
|
||||
)
|
||||
|
||||
// Enough weeks either side of today for the continuous list to fill the
|
||||
// viewport and still have somewhere to scroll.
|
||||
val centre = weekIndexOf(today, weekStart)
|
||||
val window = (centre - 8)..(centre + 8)
|
||||
// This month and the next: enough for the preview to show a month block, the
|
||||
// whitespace under it and the following month's header coming up.
|
||||
val centre = monthIndexOf(ym)
|
||||
val window = centre..(centre + 1)
|
||||
val continuous = ContinuousMonthUiState.Success(
|
||||
today = today,
|
||||
weeksByIndex = window.associateWith { index ->
|
||||
val days = (0 until 7).map {
|
||||
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||
}
|
||||
layoutCalendarWeek(days, events, zone)
|
||||
monthsByIndex = window.associateWith { index ->
|
||||
val m = yearMonthForIndex(index)
|
||||
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
|
||||
},
|
||||
weekStart = weekStart,
|
||||
)
|
||||
|
||||
@@ -40,18 +40,21 @@ data class MonthWeek(
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||
* absolute month index and carries only its own days — the boundary week keeps
|
||||
* its seven columns so the grid geometry never shifts, but the neighbour month's
|
||||
* 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 {
|
||||
data object Loading : ContinuousMonthUiState
|
||||
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||
data class Success(
|
||||
val today: LocalDate,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||
val weekStart: DayOfWeek,
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.stateIn
|
||||
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.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
@@ -102,24 +102,30 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
// --- 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.
|
||||
// The continuous grid scrolls through every month there is, so it can't load
|
||||
// one month at a time — it loads a sliding window of month indices around
|
||||
// whatever is on screen. The window only moves when the visible range comes
|
||||
// within WINDOW_EDGE months 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 },
|
||||
// Seeded around today so the first frame has data.
|
||||
private val _loadedMonths = MutableStateFlow(
|
||||
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
.let { it - WINDOW_PAD..it + WINDOW_PAD },
|
||||
)
|
||||
|
||||
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||
combine(_loadedWeeks, weekStart) { window, ws -> window to ws }
|
||||
combine(_loadedMonths, 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)
|
||||
// Widened to whole grid weeks at both ends: a month block still
|
||||
// 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)
|
||||
combine(
|
||||
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
|
||||
* 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 }
|
||||
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
||||
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it }
|
||||
}
|
||||
|
||||
private fun buildContinuousState(
|
||||
@@ -154,15 +160,13 @@ class MonthViewModel @Inject constructor(
|
||||
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)
|
||||
val months = window.associateWith { index ->
|
||||
val ym = yearMonthForIndex(index)
|
||||
layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) }
|
||||
}
|
||||
return ContinuousMonthUiState.Success(
|
||||
today = todayDate,
|
||||
weeksByIndex = weeks,
|
||||
monthsByIndex = months,
|
||||
weekStart = weekStart,
|
||||
)
|
||||
}
|
||||
@@ -252,12 +256,8 @@ internal fun layoutMonthWeeks(
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<MonthWeek> {
|
||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(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
|
||||
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||
|
||||
return (0 until weekCount).map { row ->
|
||||
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
|
||||
* 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_EDGE = 4
|
||||
private const val WINDOW_PAD = 4
|
||||
private const val WINDOW_EDGE = 2
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (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 continuous grid addresses months by an absolute index, so the list has one
|
||||
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||
*
|
||||
* 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.
|
||||
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||
* 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 val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||
private const val MONTH_INDEX_END_YEAR = 2100
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||
|
||||
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)
|
||||
|
||||
/** Total weeks the continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
/**
|
||||
* Strip everything outside [month] from a boundary week row, for the continuous
|
||||
* style's self-contained month blocks.
|
||||
*
|
||||
* The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
|
||||
* 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 {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user