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

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

View File

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

View File

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

View File

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

View File

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