@@ -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