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
2 changed files with 206 additions and 14 deletions
Showing only changes of commit b417900ddd - Show all commits

View File

@@ -85,8 +85,12 @@ class MonthViewModel @Inject constructor(
val month: StateFlow<YearMonth> = _month val month: StateFlow<YearMonth> = _month
val state: StateFlow<MonthUiState> = val state: StateFlow<MonthUiState> =
combine(_month, weekStart) { ym, ws -> ym to ws } combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
.flatMapLatest { (ym, ws) -> .flatMapLatest { (ym, ws, style) ->
// The mirror of the gate below: the paged grid is what Paged and
// Split draw, so under a scrolling style this query is a month
// of provider work for a view that isn't on screen.
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
val range = monthGridRange(ym, ws, zone) val range = monthGridRange(ym, ws, zone)
combine( combine(
repository.calendars(), repository.calendars(),
@@ -156,7 +160,7 @@ class MonthViewModel @Inject constructor(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(range),
) { calendars, instances -> ) { calendars, instances ->
buildContinuousState(window, ws, calendars, instances) buildContinuousState(window, ws, style, calendars, instances)
} }
} }
// A load landing is the cue to reach further out. Widening from here // A load landing is the cue to reach further out. Widening from here
@@ -198,23 +202,35 @@ class MonthViewModel @Inject constructor(
private fun buildContinuousState( private fun buildContinuousState(
window: IntRange, window: IntRange,
weekStart: DayOfWeek, weekStart: DayOfWeek,
style: MonthViewStyle,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): ContinuousMonthUiState { ): ContinuousMonthUiState {
if (calendars.isEmpty()) { if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val months = window.associateWith { index -> // Bucketed once for the whole window: a row then takes the handful of
val ym = yearMonthForIndex(index) // events on its seven days instead of re-scanning a year of them.
layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } val byDay = DayIndex(instances, zone)
} // Only the style on screen is laid out. Building both doubled the work
// The Dense style's rows, from the same query: every week the window's // for a layout nothing was going to draw.
// months touch, laid out whole rather than clipped to a month. val months = if (style == MonthViewStyle.Continuous) {
val weeks = weekWindowFor(window, weekStart).associateWith { index -> window.associateWith { index ->
val days = (0 until 7).map { val ym = yearMonthForIndex(index)
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) }
} }
layoutCalendarWeek(days, instances, zone) } else {
emptyMap()
}
val weeks = if (style == MonthViewStyle.Dense) {
weekWindowFor(window, weekStart).associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
}
} else {
emptyMap()
} }
return ContinuousMonthUiState.Success( return ContinuousMonthUiState.Success(
today = todayDate, today = todayDate,
@@ -308,13 +324,68 @@ internal fun layoutMonthWeeks(
weekStart: DayOfWeek, weekStart: DayOfWeek,
instances: List<EventInstance>, instances: List<EventInstance>,
zone: TimeZone, zone: TimeZone,
): List<MonthWeek> = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone)
/**
* As above, but reusing a [DayIndex] already built for a wider range — what the
* scrolling styles need, since they lay out many months from one query and
* rebuilding the index per month would put the cost straight back.
*/
internal fun layoutMonthWeeks(
ym: YearMonth,
weekStart: DayOfWeek,
byDay: DayIndex,
zone: TimeZone,
): List<MonthWeek> { ): List<MonthWeek> {
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
val weekCount = weekRowsInMonth(ym, weekStart) val weekCount = weekRowsInMonth(ym, weekStart)
return (0 until weekCount).map { row -> return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
layoutCalendarWeek(days, instances, zone) layoutCalendarWeek(days, byDay.eventsOn(days), zone)
}
}
/**
* Events bucketed by the dates they cover, built once per load.
*
* [layoutCalendarWeek] opens by filtering the instances it was handed down to
* the ones touching its seven days. That is fine for a single month's grid, but
* the scrolling styles lay out a hundred-odd rows from one query, and scanning
* *every* instance in an eleven-month window for each of them made the work grow
* with the number of enabled calendars until opening the view stalled.
*
* Membership is decided by [coversDay] itself rather than by re-deriving the
* rule from the timestamps: the all-day and timed cases have enough edge cases
* between them (UTC anchoring, exclusive ends, zero-length events at midnight)
* that a second implementation would drift. The walk only visits an event's own
* candidate days, so the check runs a couple of times per event rather than once
* per event per row.
*/
internal class DayIndex(instances: List<EventInstance>, private val zone: TimeZone) {
private val byDay: Map<LocalDate, List<EventInstance>> = buildMap<LocalDate, MutableList<EventInstance>> {
instances.forEach { event ->
// All-day events are anchored to UTC midnights; timed ones to the
// device zone. Either way the candidate span is the event's own
// dates, which is one or two days for almost everything.
val anchor = if (event.isAllDay) TimeZone.UTC else zone
var day = event.start.toLocalDateTime(anchor).date
val lastCandidate = event.end.toLocalDateTime(anchor).date
while (day <= lastCandidate) {
if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event)
day = day.plus(1, DateTimeUnit.DAY)
}
}
}
/** Every event touching any of [days], each listed once, in input order. */
fun eventsOn(days: List<LocalDate>): List<EventInstance> {
if (days.isEmpty()) return emptyList()
val hits = days.flatMap { byDay[it].orEmpty() }
// A multi-day event is bucketed on each date it covers, so a row that
// holds several of its days would otherwise list it several times.
return if (hits.size < 2) hits else hits.distinctBy { it.instanceId }
} }
} }

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* [DayIndex] exists purely to make the scrolling styles cheap, so what it owes
* is that cheapness changes *nothing*: a row laid out from the index must be the
* row that would have come from scanning every instance.
*
* Run in a zone east of UTC, where the all-day anchoring and the day boundaries
* disagree — the case that has bitten this codebase before (#65).
*/
class DayIndexTest {
private val zone = TimeZone.of("Europe/Berlin")
private val mon = LocalDate(2026, 6, 8)
private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) }
private var nextId = 0L
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = from.atTime(startHour, 0).toInstant(zone),
end = to.atTime(endHour, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
}
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
}
/** Every shape the layout distinguishes, plus the boundary cases around them. */
private fun awkwardEvents(): List<EventInstance> = listOf(
timed(mon, 9, mon, 10),
// Crosses midnight into the next day.
timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1),
// Ends exactly at midnight: covers its own day, not the next.
timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0),
// Zero length, at midnight: covers nothing at all.
timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0),
allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)),
// Runs in from before the row and out past its end.
allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)),
// Wholly outside the row, both sides.
timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10),
allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)),
// Two on one day, to pin the ordering the index hands back.
timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9),
timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15),
)
@Test
fun `a row from the index is the row from a full scan`() {
val events = awkwardEvents()
val scanned = layoutCalendarWeek(week, events, zone)
val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone)
assertThat(indexed).isEqualTo(scanned)
}
@Test
fun `a whole month agrees, row for row`() {
val events = awkwardEvents()
val jun = YearMonth(2026, Month.JUNE)
DayOfWeek.entries.forEach { ws ->
assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone))
.isEqualTo(layoutMonthWeeks(jun, ws, events, zone))
}
}
@Test
fun `an event covering several of a row's days is listed once`() {
// It is bucketed on each date it covers, so the row would otherwise see
// it repeatedly and lay out a lane per copy.
val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY))
val events = DayIndex(listOf(long), zone).eventsOn(week)
assertThat(events).containsExactly(long)
}
@Test
fun `days no event touches come back empty`() {
val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone)
assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty()
assertThat(index.eventsOn(emptyList())).isEmpty()
}
@Test
fun `an empty calendar indexes to nothing`() {
assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty()
}
}