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
3 changed files with 42 additions and 3 deletions
Showing only changes of commit ee9d65068d - Show all commits

View File

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

View File

@@ -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
* 19002100 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. */

View File

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