fix(month): don't drag the continuous window back to 1900 (#38)
The continuous grid failed outright with "Could not read the calendar" the moment it opened. Before the first layout pass LazyListState reports no visible items, and the screen defaulted that to item 0 — claiming January 1900 was on screen. The load window widened around it, its pad ran past the epoch to index -4, and yearMonthForIndex indexed Month.entries out of bounds; the flow's catch turned the crash into the generic provider failure. Week indexing never showed this: index 0 was a real week, so a bogus report only cost one wasted query. - The visible-range report is skipped entirely while the list has no items, rather than standing in a default for them. - clampMonthWindow holds any window inside 1900–2100, so a pad at either end can't produce an index with no month behind it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user