From 8cfa3619316053b7565e5cd1b034dedfec8f559e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 14:35:38 +0200 Subject: [PATCH] Page the week, multi-day and day timelines with one DaysGrid --- .../calendula/ui/week/WeekPaging.kt | 101 ++++++------------ .../calendula/ui/week/WeekScreen.kt | 9 +- .../calendula/ui/week/WeekViewModel.kt | 20 ++-- .../calendula/ui/week/FocusOnPageTest.kt | 8 +- .../calendula/ui/week/PageNumberingTest.kt | 24 +++-- .../calendula/ui/week/WeekPagingTest.kt | 26 ++--- 6 files changed, 82 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index 68c08aba..150b3f43 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -7,100 +7,63 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.daysUntil import kotlinx.datetime.plus -/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */ +/** Pages the week pager spans — a little under four centuries. */ internal const val WEEK_PAGE_COUNT: Int = 20_000 -private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2 - -/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */ -private val PAGE_EPOCH = LocalDate(2000, 1, 3) - -/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */ -internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate = - PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY) - -/** The pager page whose week contains [date], with weeks starting on [firstDay]. */ -internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int { - val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay)) - return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1) -} - -/** How a timeline pager cuts the calendar into pages of [dayCount] days. */ -sealed interface PageGrid { - val dayCount: Int - val pageCount: Int - - /** First day shown on pager [page]. */ - fun startForPage(page: Int): LocalDate - - /** The pager page showing [date]. */ - fun pageFor(date: LocalDate): Int -} - -/** Whole weeks, starting on [firstDay]. */ -data class WeekGrid(val firstDay: DayOfWeek) : PageGrid { - override val dayCount: Int get() = 7 - override val pageCount: Int get() = WEEK_PAGE_COUNT - override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay) - override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay) -} - /** Pages the multi-day pager spans — over three centuries at the widest page. */ internal const val DAYS_PAGE_COUNT: Int = 40_000 -private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2 +/** Pages the day pager spans — a little under four centuries. */ +internal const val DAY_PAGE_COUNT: Int = 140_000 -/** A fixed day the multi-day pages are counted from. */ +/** A fixed day every grid's pages are counted from, at its middle page. */ private val DAYS_EPOCH = LocalDate(2000, 1, 1) +/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */ +private val WEEK_EPOCH = LocalDate(2000, 1, 3) + /** - * Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any - * date can start a page. The phase stays put while paging and only changes when - * a jump moves another date to the first column. + * How a timeline pager cuts the calendar: runs of [dayCount] days, offset + * [phase] days from a fixed epoch, over [pageCount] pages centred on it. The + * multi-day view's phase (#193) stays put while paging and only changes when a + * jump moves another date to the first column. */ -data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid { - override val pageCount: Int get() = DAYS_PAGE_COUNT +data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) { - override fun startForPage(page: Int): LocalDate = - DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY) + private val epochPage: Int get() = pageCount / 2 - override fun pageFor(date: LocalDate): Int = - (DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) - .coerceIn(0, DAYS_PAGE_COUNT - 1) + /** First day shown on pager [page]. */ + fun startForPage(page: Int): LocalDate = + DAYS_EPOCH.plus(phase + (page - epochPage) * dayCount, DateTimeUnit.DAY) + + /** The pager page showing [date]. */ + fun pageFor(date: LocalDate): Int = + (epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) + .coerceIn(0, pageCount - 1) companion object { + /** Single days: every date starts a page of its own. */ + val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT) + + /** Whole weeks, starting on [firstDay]. */ + fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid( + dayCount = 7, + phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)), + pageCount = WEEK_PAGE_COUNT, + ) + /** The grid of [dayCount]-day pages on which [date] is a page's first day. */ fun startingAt(date: LocalDate, dayCount: Int): DaysGrid = DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount)) } } -/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */ -internal const val DAY_PAGE_COUNT: Int = 140_000 - -private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2 - -/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */ -private val DAY_EPOCH = LocalDate(2000, 1, 1) - -/** Single days: every date starts a page of its own. */ -data object DayGrid : PageGrid { - override val dayCount: Int get() = 1 - override val pageCount: Int get() = DAY_PAGE_COUNT - - override fun startForPage(page: Int): LocalDate = - DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY) - - override fun pageFor(date: LocalDate): Int = - (DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1) -} - /** * Where the focused date goes when the pager settles on [page] (#184): kept if * the page still shows it, otherwise moved by the pages travelled, so it keeps * its place in the page — the same weekday a week on. */ -internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate { +internal fun DaysGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate { val start = startForPage(page) val end = start.plus(dayCount - 1, DateTimeUnit.DAY) if (focus in start..end) return focus diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 6eaa28e0..e19045ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -464,11 +464,12 @@ private fun PagedTimelineScreen( // Straight to the pager: a tap mid-fling must still land, and the anchor // hasn't caught up with the fling yet to tell the difference. A date that - // would open mid-page on this grid re-frames the pages around it instead. + // would open mid-page on this grid, in a view that frames its pages from the + // date (multi-day), re-frames the pages around it instead. val jumpToDate: (LocalDate) -> Unit = { target -> focus?.date = target val page = grid.pageFor(target) - if (grid is DaysGrid && grid.startForPage(page) != target) { + if (grid.startForPage(page) != target && viewModel.rule.value(target) != grid) { viewModel.jumpTo(target) } else { scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) } @@ -531,7 +532,7 @@ private fun WeekContent( state: WeekUiState, today: LocalDate, pagerState: PagerState, - grid: PageGrid, + grid: DaysGrid, kind: TimelineKind, morph: TimelineMorph?, handoffStrip: Dp?, @@ -696,7 +697,7 @@ private fun WeekContent( @Composable private fun WeekPager( pagerState: PagerState, - grid: PageGrid, + grid: DaysGrid, kind: TimelineKind, span: (DaySpan) -> StateFlow, today: LocalDate, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 4128ef77..fe5992fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -62,10 +62,10 @@ private const val SPAN_CACHE_DAYS = 28 data class DaySpan(val start: LocalDate, val count: Int) /** How the pager cuts the calendar, and the page the anchor sits on. */ -data class PagerPosition(val grid: PageGrid, val page: Int) +data class PagerPosition(val grid: DaysGrid, val page: Int) /** Where [anchor] sits under [rule]. */ -fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition = +fun positionOf(anchor: LocalDate, rule: (LocalDate) -> DaysGrid): PagerPosition = rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } /** @@ -84,8 +84,8 @@ abstract class PagedTimelineViewModel( todaySource: TodaySource, zoneSource: ZoneSource, private val io: CoroutineDispatcher, - gridRule: Flow<(LocalDate) -> PageGrid>, - initialRule: (LocalDate) -> PageGrid, + gridRule: Flow<(LocalDate) -> DaysGrid>, + initialRule: (LocalDate) -> DaysGrid, ) : ViewModel() { /** @@ -121,7 +121,7 @@ abstract class PagedTimelineViewModel( * The grid for a given anchor, held eagerly so a screen can place its pager * on its very first frame rather than a frame after the anchor moved. */ - val rule: StateFlow<(LocalDate) -> PageGrid> = + val rule: StateFlow<(LocalDate) -> DaysGrid> = gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule) /** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */ @@ -233,11 +233,11 @@ class WeekViewModel @Inject constructor( zoneSource = zoneSource, io = io, gridRule = settingsPrefs.weekStart.map { pref -> - val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault())) - val rule: (LocalDate) -> PageGrid = { grid } + val grid = DaysGrid.weeks(pref.resolveFirstDay(Locale.getDefault())) + val rule: (LocalDate) -> DaysGrid = { grid } rule }, - initialRule = { WeekGrid(DayOfWeek.MONDAY) }, + initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) }, ) /** @@ -278,8 +278,8 @@ class DayViewModel @Inject constructor( todaySource = todaySource, zoneSource = zoneSource, io = io, - gridRule = flowOf { _: LocalDate -> DayGrid }, - initialRule = { DayGrid }, + gridRule = flowOf { _: LocalDate -> DaysGrid.days }, + initialRule = { DaysGrid.days }, ) /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt index 96e5025b..6683dd53 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt @@ -13,13 +13,13 @@ class FocusOnPageTest { @Test fun `a page that still shows the focus keeps it`() { - val grid = WeekGrid(DayOfWeek.MONDAY) + val grid = DaysGrid.weeks(DayOfWeek.MONDAY) assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday) } @Test fun `a week on keeps the weekday`() { - val grid = WeekGrid(DayOfWeek.MONDAY) + val grid = DaysGrid.weeks(DayOfWeek.MONDAY) val page = grid.pageFor(wednesday) assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7)) assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16)) @@ -34,7 +34,7 @@ class FocusOnPageTest { @Test fun `a day page is the focus`() { - val page = DayGrid.pageFor(wednesday) - assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5)) + val page = DaysGrid.days.pageFor(wednesday) + assertThat(DaysGrid.days.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5)) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt index 5e5e9bf8..93a5814f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt @@ -35,7 +35,7 @@ class PageNumberingTest { @Test fun `week pages keep their numbering for every first day`() { DayOfWeek.entries.forEach { firstDay -> - val grid = WeekGrid(firstDay) + val grid = DaysGrid.weeks(firstDay) assertThat(grid.dayCount).isEqualTo(7) assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT) pages(LEGACY_WEEK_COUNT).forEach { page -> @@ -69,7 +69,7 @@ class PageNumberingTest { @Test fun `day pages keep their numbering`() { - val grid = DayGrid + val grid = DaysGrid.days assertThat(grid.dayCount).isEqualTo(1) assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT) pages(LEGACY_DAY_COUNT).forEach { page -> @@ -80,15 +80,27 @@ class PageNumberingTest { } } + @Test + fun `a multi-day jump re-frames exactly when the date would open mid-page`() { + val inRange = dates.filter { it.year in 1950..2050 } + (3..6).forEach { count -> + val grid = DaysGrid.startingAt(LocalDate(2026, 10, 16), count) + inRange.forEach { target -> + val midPage = grid.startForPage(grid.pageFor(target)) != target + assertThat(DaysGrid.startingAt(target, count) != grid).isEqualTo(midPage) + } + } + } + @Test fun `grids are equal exactly when they page the same way`() { - assertThat(WeekGrid(DayOfWeek.MONDAY)).isEqualTo(WeekGrid(DayOfWeek.MONDAY)) - assertThat(WeekGrid(DayOfWeek.MONDAY)).isNotEqualTo(WeekGrid(DayOfWeek.SUNDAY)) + assertThat(DaysGrid.weeks(DayOfWeek.MONDAY)).isEqualTo(DaysGrid.weeks(DayOfWeek.MONDAY)) + assertThat(DaysGrid.weeks(DayOfWeek.MONDAY)).isNotEqualTo(DaysGrid.weeks(DayOfWeek.SUNDAY)) assertThat(DaysGrid(3, 1)).isEqualTo(DaysGrid(3, 1)) assertThat(DaysGrid(3, 1)).isNotEqualTo(DaysGrid(3, 2)) assertThat(DaysGrid(3, 1)).isNotEqualTo(DaysGrid(4, 1)) - assertThat(DayGrid).isEqualTo(DayGrid) - assertThat(DayGrid.hashCode()).isEqualTo(DayGrid.hashCode()) + assertThat(DaysGrid.days).isEqualTo(DaysGrid.days) + assertThat(DaysGrid.days.hashCode()).isEqualTo(DaysGrid.days.hashCode()) } private companion object { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt index 9199ec4e..ef014dab 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -16,34 +16,34 @@ class WeekPagingTest { @Test fun `a page and its week round-trip for every first day`() { DayOfWeek.entries.forEach { firstDay -> - val page = weekPageFor(sunday, firstDay) - val start = weekStartForPage(page, firstDay) + val page = DaysGrid.weeks(firstDay).pageFor(sunday) + val start = DaysGrid.weeks(firstDay).startForPage(page) assertThat(start).isEqualTo(sunday.startOfWeek(firstDay)) - assertThat(weekPageFor(start, firstDay)).isEqualTo(page) + assertThat(DaysGrid.weeks(firstDay).pageFor(start)).isEqualTo(page) } } @Test fun `every day of a week maps to the same page`() { val start = sunday.startOfWeek(DayOfWeek.MONDAY) - val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + val pages = (0..6).map { DaysGrid.weeks(DayOfWeek.MONDAY).pageFor(start.plus(it, DateTimeUnit.DAY)) } assertThat(pages.toSet()).hasSize(1) } @Test fun `adjacent pages are adjacent weeks`() { - val page = weekPageFor(sunday, DayOfWeek.SUNDAY) - assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY)) + val page = DaysGrid.weeks(DayOfWeek.SUNDAY).pageFor(sunday) + assertThat(DaysGrid.weeks(DayOfWeek.SUNDAY).startForPage(page + 1)) .isEqualTo(LocalDate(2026, 10, 4)) - assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY)) + assertThat(DaysGrid.weeks(DayOfWeek.SUNDAY).startForPage(page - 1)) .isEqualTo(LocalDate(2026, 9, 20)) } @Test fun `dates before the epoch page resolve too`() { val old = LocalDate(1990, 5, 16) - val page = weekPageFor(old, DayOfWeek.MONDAY) - assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14)) + val page = DaysGrid.weeks(DayOfWeek.MONDAY).pageFor(old) + assertThat(DaysGrid.weeks(DayOfWeek.MONDAY).startForPage(page)).isEqualTo(LocalDate(1990, 5, 14)) } @Test @@ -69,14 +69,14 @@ class WeekPagingTest { @Test fun `a day page and its day round-trip`() { listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> - assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day) + assertThat(DaysGrid.days.startForPage(DaysGrid.days.pageFor(day))).isEqualTo(day) } } @Test fun `adjacent day pages are adjacent days across a month end`() { - val page = DayGrid.pageFor(LocalDate(2026, 2, 28)) - assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) - assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + val page = DaysGrid.days.pageFor(LocalDate(2026, 2, 28)) + assertThat(DaysGrid.days.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(DaysGrid.days.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) } }