diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 40474b5..a45ab09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -101,6 +101,7 @@ 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.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -176,10 +177,18 @@ fun MonthScreen( LaunchedEffect(listState, continuous) { if (!continuous) return@LaunchedEffect snapshotFlow { + // Empty before the first layout pass — and reporting a default of 0 + // for it would claim January 1900 is on screen and drag the whole + // window back there. val visible = listState.layoutInfo.visibleItemsInfo - monthIndexForItem(visible.firstOrNull()?.index ?: 0) to - monthIndexForItem(visible.lastOrNull()?.index ?: 0) + if (visible.isEmpty()) { + null + } else { + monthIndexForItem(visible.first().index) to + monthIndexForItem(visible.last().index) + } } + .filterNotNull() .distinctUntilChanged() .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index dd3e64b..d94d4b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -148,7 +148,8 @@ class MonthViewModel @Inject constructor( * close enough to a loaded edge to warrant a wider query. */ fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it } + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex) + ?.let { _loadedMonths.value = clampMonthWindow(it) } } private fun buildContinuousState( @@ -402,6 +403,16 @@ internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2 internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 +/** + * Hold a load window inside the months that actually exist. The pad + * [nextLoadWindow] adds can otherwise push an index past either end of the + * 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. + */ +internal fun clampMonthWindow(window: IntRange): IntRange { + val last = continuousMonthCount() - 1 + return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) +} + 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. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 22e0529..c637c3d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -88,6 +88,25 @@ class ContinuousMonthIndexTest { } } + @Test + fun `a window is clamped to the months that exist`() { + // The regression this guards: an empty visible-items list reported as + // "month 0", whose padded window ran to -4 — and yearMonthForIndex has + // no month for that, so the whole grid failed with "couldn't read". + assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4) + val last = continuousMonthCount() - 1 + assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last) + assertThat(clampMonthWindow(10..20)).isEqualTo(10..20) + } + + @Test + fun `every index a clamped window can hold resolves to a month`() { + listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3))) + .forEach { window -> + window.forEach { index -> yearMonthForIndex(index) } + } + } + @Test fun `firstOfMonth is the 1st`() { assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))