feat(month): continuous vertical scrolling (#38)

Adds MonthViewStyle.Continuous: one uninterrupted vertical stream of weeks
instead of month pages.

The layout unit is the ISO week, not the month. A vertical stack of month grids
would still repeat a boundary week at the end of one month and the start of the
next, which is precisely the duplication the issue asks to be rid of. Streaming
weeks means every date appears exactly once.

- Weeks are addressed by an absolute index (epoch 1900, so indices stay
  non-negative and map 1:1 onto LazyColumn item indices), giving the list one
  stable, gap-free coordinate space to scroll and key by.
- The view model loads a sliding window of weeks around the visible range.
  nextLoadWindow() holds the hysteresis: the window only moves once the visible
  range comes within four weeks of a loaded edge, so scrolling re-queries the
  provider occasionally rather than every frame. Unloaded rows render a
  same-height skeleton, so nothing jumps when the window catches up.
- No dimmed "other month" days — every day in the stream belongs to a month
  equally. Instead the 1st names its month, which is the only marker needed to
  tell one month from the next, and the top bar title tracks the month the
  viewport mostly sits in.
- The horizontal swipe detector and the paged AnimatedContent are both off in
  this style; vertical scrolling owns the gesture. Today and drawer jump-to-date
  animate the list instead of swapping months. The view opens positioned on the
  current month.

MonthWeekRow now takes inMonth as a predicate rather than a YearMonth, which is
what lets the same row serve a stream that has no enclosing month.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 16:54:41 +02:00
parent 7510e1f9af
commit 542744e342
4 changed files with 485 additions and 35 deletions

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