Merge the month seating copies, resize state and page grids (#418)
Step 8 of the simplification review: the three behaviour-sensitive merges. Each lands behind a characterization test written and passed against the old code first. **Month row seating** - `MonthWeek.seating(col, laneCap)` is the one source for which event sits in which lane and what overflows. `laneEvents`, `chipAt`, `overflowEvents` and MonthWeekRow's inline copy all build on it, so dots, bars and pills can't drift apart (#53). - MonthWeekRow computes the seating once per row (`remember(week, laneCap)`) instead of re-filtering every column on each recomposition. - `OverflowDots.total` and `shownLanes` are gone; they always equalled `events.size` and `laneCap`. - MonthSeatingTest pins the four old copies against each other over 400 generated weeks × lane caps 0–6; they agreed everywhere, and `seating()` matches them. **Timeline resize** - TimelineDragController's eight loose `resize*` fields are one `ResizeSession?`, so cancel clears it in one assignment. Moves mutate it in place; no per-frame allocation. None of the fields were Compose state. - Only difference: a resize starting without the previous one ending used to inherit the old held slot; it now starts clean. **Timeline paging** - Week, multi-day and day page through one `DaysGrid` with `weeks(firstDay)`, `days` and `startingAt(date, count)` factories; the PageGrid interface, WeekGrid and DayGrid are gone. Page numbers and grid equality (used by pager keys) are unchanged; PageNumberingTest pins them. - jumpToDate no longer detects the multi-day view by class; it re-frames when the target would open mid-page, which a test checks against the old rule for every in-range date. Not yet checked on a device. Worth a look: month bars/pills/+N dots on crowded days in paged and split styles plus the split morph, chip taps and month drag; resize of both edges incl. across midnight, cancel then plain drag, RTL; paging, view switching, jump to date/today, week-start and multi-day count changes. Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/418
This commit is contained in:
@@ -626,25 +626,8 @@ class TimelineDragController {
|
||||
*/
|
||||
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||
private var resizeEdge: ResizeEdge? = null
|
||||
|
||||
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||
private var resizeDay: LocalDate? = null
|
||||
private var resizeStartMin = 0
|
||||
private var resizeEndMin = 0
|
||||
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
private var resizeGrabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
private var resizeColumn: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
private var resizeSlot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
private var resizeEdgeDay = 0
|
||||
/** The resize in progress, or null for a plain drag (#141). */
|
||||
private var resize: ResizeSession? = null
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
@@ -691,10 +674,7 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
resizeColumn = null
|
||||
resizeSlot = null
|
||||
resize = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -800,11 +780,9 @@ class TimelineDragController {
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
|
||||
source = block
|
||||
resizeEdge = edge
|
||||
resizeDay = span.day
|
||||
resizeStartMin = span.startMin
|
||||
resizeEndMin = span.endMin
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
@@ -813,12 +791,12 @@ class TimelineDragController {
|
||||
liftedInstanceId = block.event.instanceId
|
||||
pointer = pointerInRoot
|
||||
// Measured from the edge's own column: the handle overhangs into the next.
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (session.endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
session.column = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { column(it) }
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
@@ -834,7 +812,7 @@ class TimelineDragController {
|
||||
fun preview(zone: TimeZone): EventInstance? {
|
||||
val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null
|
||||
val event = source?.event ?: return null
|
||||
val edge = resizeEdge
|
||||
val edge = resize?.edge
|
||||
if (edge != null) {
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
@@ -850,7 +828,7 @@ class TimelineDragController {
|
||||
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
val edge = resizeEdge
|
||||
val edge = resize?.edge
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
@@ -862,9 +840,8 @@ class TimelineDragController {
|
||||
* Where the finger is, in minutes from the resized event's start day —
|
||||
* past 1440 in a later column, negative in an earlier one.
|
||||
*/
|
||||
private fun pointerResizeMinutes(): Float? {
|
||||
private fun pointerResizeMinutes(session: ResizeSession): Float? {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||
val day = resizeDay ?: return null
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
@@ -872,22 +849,23 @@ class TimelineDragController {
|
||||
val origin = grid.positionInRoot()
|
||||
val held = stickyColumn(
|
||||
position = (pointer.x - origin.x) / columnPx,
|
||||
held = resizeColumn,
|
||||
held = session.column,
|
||||
lastIndex = days.lastIndex,
|
||||
)
|
||||
resizeColumn = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
|
||||
resizeEdgeDay = dayOffset
|
||||
session.column = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||
session.edgeDay = dayOffset
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
}
|
||||
|
||||
private fun recomputeResize(edge: ResizeEdge) {
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
val block = source ?: return
|
||||
val day = resizeDay ?: return
|
||||
val raw = pointerResizeMinutes() ?: return
|
||||
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
|
||||
resizeSlot = slot
|
||||
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
|
||||
val edge = session.edge
|
||||
val day = session.day
|
||||
val raw = pointerResizeMinutes(session) ?: return
|
||||
val slot = stickySnap(raw - session.grabMin, session.slot)
|
||||
session.slot = slot
|
||||
val (start, end) = resizedSpan(edge, session.startMin, session.endMin, slot.toFloat(), session.edgeDay)
|
||||
val days = geometry.days
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
@@ -946,7 +924,7 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resizeEdge?.let {
|
||||
resize?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
@@ -1049,6 +1027,27 @@ class TimelineDragController {
|
||||
}
|
||||
}
|
||||
|
||||
/** One resize gesture (#141): the edge it drags, and the edges as minutes from [day]'s midnight. */
|
||||
private class ResizeSession(
|
||||
val edge: ResizeEdge,
|
||||
/** The day the resized event starts on. */
|
||||
val day: LocalDate,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
) {
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
var grabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
var column: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
var slot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
var edgeDay = 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTimelineDragController(): TimelineDragController {
|
||||
val controller = remember { TimelineDragController() }
|
||||
|
||||
@@ -841,7 +841,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp =
|
||||
|
||||
/** Whether any day in this week holds more than [lanes] lanes can seat. */
|
||||
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
|
||||
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
|
||||
days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
@@ -1627,11 +1627,11 @@ internal fun SplitMonthGrid(
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
||||
val seated = week.laneEvents(col, SPLIT_DOT_LANES)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = seated,
|
||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
||||
hidden = week.overflowEvents(col, SPLIT_DOT_LANES),
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -2098,8 +2098,8 @@ private fun MonthWeekRow(
|
||||
val ripple = rememberTodayRipple(active = todayCol != null)
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
||||
// Once per row rather than per frame: a drag recomposes every frame.
|
||||
val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } }
|
||||
val morphing = morphInFlight()
|
||||
// Every chip's start time for this row at once, and only when the row's own
|
||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||
@@ -2283,7 +2283,7 @@ private fun MonthWeekRow(
|
||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
week.spans.filter { it.lane < laneCap }.forEach { span ->
|
||||
val cols = span.endCol - span.startCol + 1
|
||||
MonthBar(
|
||||
event = span.event,
|
||||
@@ -2355,19 +2355,13 @@ private fun MonthWeekRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||
// bar-free day isn't pushed down by a multi-day event that only
|
||||
// sits on other days of the week.
|
||||
// Single-day timed pills + overflow, per column, in the lanes
|
||||
// [seating] gave them.
|
||||
week.days.forEachIndexed { col, d ->
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
val seating = seatings[col]
|
||||
seating.seated.forEach { seat ->
|
||||
if (seat.bar) return@forEach
|
||||
val ev = seat.event
|
||||
MonthBar(
|
||||
event = ev,
|
||||
dark = dark,
|
||||
@@ -2379,7 +2373,7 @@ private fun MonthWeekRow(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
y = EVENT_ROW_HEIGHT * seat.lane,
|
||||
)
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.then(
|
||||
@@ -2395,18 +2389,10 @@ private fun MonthWeekRow(
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenEvents = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event) }
|
||||
addAll(timed.drop(pillsShown.size))
|
||||
}
|
||||
if (seating.hidden.isNotEmpty()) {
|
||||
OverflowDots(
|
||||
events = hiddenEvents,
|
||||
events = seating.hidden,
|
||||
day = d,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
rowHeight = overflowRow,
|
||||
modifier = Modifier
|
||||
@@ -2786,7 +2772,6 @@ private fun MonthBar(
|
||||
private fun OverflowDots(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
rowHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -2808,7 +2793,7 @@ private fun OverflowDots(
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = total - dots.size
|
||||
val extra = events.size - dots.size
|
||||
if (extra > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
|
||||
@@ -39,33 +39,55 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */
|
||||
data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean)
|
||||
|
||||
/**
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
* How one day of a week row is seated with [laneCap] lanes: what sits in each
|
||||
* lane, top-most first with empty lanes left out, and what had no lane left.
|
||||
*/
|
||||
data class DaySeating(val seated: List<Seat>, val hidden: List<EventInstance>)
|
||||
|
||||
/**
|
||||
* The seating of column [col] with [laneCap] lanes — the one every reader of a
|
||||
* row builds on: the bars and pills the paged grid draws, the split style's dots
|
||||
* ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]).
|
||||
*
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
* Bars keep the lane the row layout gave them, and the day's timed events fill
|
||||
* whatever slots are left, top-most first, so a bar-free day isn't pushed down by
|
||||
* a multi-day event that only sits on other days of the week. Overflow is the
|
||||
* exact complement, bars before pills.
|
||||
*/
|
||||
fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating {
|
||||
val covering = spans.filter { col in it.startCol..it.endCol }
|
||||
val byLane = arrayOfNulls<Seat>(laneCap.coerceAtLeast(0))
|
||||
covering.forEach { span ->
|
||||
if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true)
|
||||
}
|
||||
val timed = timedByDay[days[col]].orEmpty()
|
||||
var pills = 0
|
||||
for (lane in byLane.indices) {
|
||||
if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false)
|
||||
}
|
||||
return DaySeating(
|
||||
seated = byLane.filterNotNull(),
|
||||
hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events occupying each lane of column [col], in lane order, capped at
|
||||
* [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading
|
||||
* it here means the split style's dots and the paged style's bars describe a day
|
||||
* in the *same order*, so dot _i_ and lane _i_ are the same event and one can
|
||||
* morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
|
||||
seating(col, laneCap).seated.map { it.event }
|
||||
|
||||
/**
|
||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||
@@ -74,14 +96,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
spanAt(col, lane)?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,19 +113,11 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
* The events in column [col] that [laneEvents] had no lane left for — its exact
|
||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||
* a count because dimming is a per-event question (#79).
|
||||
*/
|
||||
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
return buildList {
|
||||
spans.forEach { span ->
|
||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||
}
|
||||
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||
}
|
||||
}
|
||||
fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List<EventInstance> = seating(col, laneCap).hidden
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<WeekUiState>,
|
||||
today: LocalDate,
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,7 @@ class LaneEventsTest {
|
||||
val week = rowOfJuly6(listOf(bar, meeting))
|
||||
|
||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||
assertThat(week.laneEvents(col = 1, laneCap = 3))
|
||||
.containsExactly(bar, meeting)
|
||||
.inOrder()
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class LaneEventsTest {
|
||||
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, monday))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
assertThat(week.laneEvents(col = 0, laneCap = 3))
|
||||
.containsExactly(monday)
|
||||
}
|
||||
|
||||
@@ -58,10 +58,9 @@ class LaneEventsTest {
|
||||
val week = rowOfJuly6(listOf(bar))
|
||||
|
||||
(1..3).forEach { col ->
|
||||
val day = LocalDate(2026, 7, 6 + col)
|
||||
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||
assertThat(week.laneEvents(col, laneCap = 3)).containsExactly(bar)
|
||||
}
|
||||
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||
assertThat(week.laneEvents(col = 4, laneCap = 3)).isEmpty()
|
||||
}
|
||||
|
||||
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||
@@ -71,7 +70,7 @@ class LaneEventsTest {
|
||||
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||
val week = rowOfJuly6(listOf(first, second))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
assertThat(week.laneEvents(col = 0, laneCap = 3))
|
||||
.containsExactly(first, second)
|
||||
.inOrder()
|
||||
}
|
||||
@@ -81,7 +80,7 @@ class LaneEventsTest {
|
||||
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
val seated = week.laneEvents(col = 0, laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||
@@ -95,7 +94,7 @@ class LaneEventsTest {
|
||||
}
|
||||
val week = rowOfJuly6(bars)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
val seated = week.laneEvents(col = 0, laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||
.containsNoneIn(seated)
|
||||
@@ -105,14 +104,13 @@ class LaneEventsTest {
|
||||
fun `overflow is exactly what seating left behind`() {
|
||||
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||
val week = rowOfJuly6(events)
|
||||
val day = LocalDate(2026, 7, 6)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = day, laneCap = 3)
|
||||
val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3)
|
||||
val seated = week.laneEvents(col = 0, laneCap = 3)
|
||||
val overflow = week.overflowEvents(col = 0, laneCap = 3)
|
||||
|
||||
assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder()
|
||||
assertThat(seated + overflow).containsExactlyElementsIn(events)
|
||||
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day])
|
||||
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[LocalDate(2026, 7, 6)])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,8 +122,7 @@ class LaneEventsTest {
|
||||
val parked = week.spans.filter { it.lane >= 3 }.map { it.event }
|
||||
|
||||
(0..2).forEach { col ->
|
||||
val day = LocalDate(2026, 7, 6 + col)
|
||||
assertThat(week.overflowEvents(col, day, laneCap = 3))
|
||||
assertThat(week.overflowEvents(col, laneCap = 3))
|
||||
.containsExactlyElementsIn(parked)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +131,7 @@ class LaneEventsTest {
|
||||
fun `a day that fits has no overflow`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)))
|
||||
|
||||
assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty()
|
||||
assertThat(week.overflowEvents(col = 0, laneCap = 3)).isEmpty()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
import de.jeanlucmakiola.calendula.testEvent
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The month row's seating — which event sits in which lane of a day, and which
|
||||
* fall to the overflow — is read in four places: the bars and pills a row draws,
|
||||
* the split style's dots ([laneEvents]), the hit test ([chipAt]) and the overflow
|
||||
* ([overflowEvents]). They must agree for dot _i_ to morph into lane _i_ (#53).
|
||||
*
|
||||
* The references below are the four copies as they were before they shared one
|
||||
* implementation, the row's taken from its composable; this pins the shared one
|
||||
* to all of them across a spread of generated weeks.
|
||||
*/
|
||||
class MonthSeatingTest {
|
||||
|
||||
private val zone = TimeZone.of("Europe/Berlin")
|
||||
private val firstMonday = LocalDate(2026, 3, 23)
|
||||
|
||||
private var overflowed = 0
|
||||
private var stackedBars = 0
|
||||
|
||||
@Test
|
||||
fun `every reader of a week sees the same seating`() {
|
||||
val random = Random(53)
|
||||
repeat(400) { round ->
|
||||
val start = firstMonday.plus(round % 9, DateTimeUnit.WEEK)
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val events = randomEvents(random, start, count = random.nextInt(0, 14))
|
||||
val laid = layoutCalendarWeek(days, events, zone)
|
||||
val weeks = listOf(laid, clipWeekToMonth(laid, YearMonth(days[3].year, days[3].month)))
|
||||
weeks.forEach { week ->
|
||||
for (laneCap in 0..6) checkWeek(week, laneCap, "round $round, laneCap $laneCap")
|
||||
if (week.spans.any { it.lane > 0 }) stackedBars++
|
||||
}
|
||||
}
|
||||
assertWithMessage("generated days with overflow").that(overflowed).isGreaterThan(500)
|
||||
assertWithMessage("generated weeks with stacked bars").that(stackedBars).isGreaterThan(100)
|
||||
}
|
||||
|
||||
private fun checkWeek(week: MonthWeek, laneCap: Int, label: String) {
|
||||
week.days.forEachIndexed { col, day ->
|
||||
val where = "$label, col $col"
|
||||
val row = rowSeating(week, col, day, laneCap)
|
||||
val dots = refLaneEvents(week, col, day, laneCap)
|
||||
val hits = (0 until laneCap).mapNotNull { lane -> refChipAt(week, col, lane, laneCap)?.let { lane to it } }
|
||||
val overflow = refOverflowEvents(week, col, day, laneCap)
|
||||
if (overflow.isNotEmpty()) overflowed++
|
||||
|
||||
assertWithMessage("$where: hit test vs row").that(hits).isEqualTo(row.seated)
|
||||
assertWithMessage("$where: dots vs row").that(dots).isEqualTo(row.seated.map { it.second })
|
||||
assertWithMessage("$where: overflow vs row").that(overflow).isEqualTo(row.hiddenEvents)
|
||||
assertWithMessage("$where: dot count vs hidden list")
|
||||
.that(row.hiddenCount.coerceAtLeast(0)).isEqualTo(row.hiddenEvents.size)
|
||||
|
||||
assertWithMessage("$where: laneEvents").that(week.laneEvents(col, laneCap)).isEqualTo(dots)
|
||||
assertWithMessage("$where: overflowEvents").that(week.overflowEvents(col, laneCap)).isEqualTo(overflow)
|
||||
(-1..laneCap).forEach { lane ->
|
||||
assertWithMessage("$where: chipAt lane $lane")
|
||||
.that(week.chipAt(col, lane, laneCap))
|
||||
.isEqualTo(refChipAt(week, col, lane, laneCap))
|
||||
}
|
||||
val seating = week.seating(col, laneCap)
|
||||
assertWithMessage("$where: seating lanes")
|
||||
.that(seating.seated.map { it.lane to it.event }).isEqualTo(row.seated)
|
||||
assertWithMessage("$where: seating hidden").that(seating.hidden).isEqualTo(row.hiddenEvents)
|
||||
}
|
||||
}
|
||||
|
||||
private fun randomEvents(random: Random, weekStart: LocalDate, count: Int): List<EventInstance> =
|
||||
(1..count).map { n ->
|
||||
val id = n.toLong()
|
||||
val day = weekStart.plus(random.nextInt(-4, 10), DateTimeUnit.DAY)
|
||||
val response = if (random.nextInt(6) == 0) EventResponse.Declined else EventResponse.Going
|
||||
when (random.nextInt(5)) {
|
||||
0, 1 -> {
|
||||
val from = day.atTime(0, 0).toInstant(TimeZone.UTC)
|
||||
val to = day.plus(random.nextInt(1, 10), DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC)
|
||||
testEvent(id = id, title = "A$id", start = from, end = to, isAllDay = true, response = response)
|
||||
}
|
||||
2 -> {
|
||||
// Crosses midnight, sometimes ending on it exactly.
|
||||
val from = day.atTime(random.nextInt(18, 24), 0).toInstant(zone)
|
||||
val to = from + (random.nextInt(1, 60) * 30).minutes
|
||||
testEvent(id = id, title = "M$id", start = from, end = to, response = response)
|
||||
}
|
||||
else -> {
|
||||
val from = day.atTime(random.nextInt(0, 23), random.nextInt(0, 4) * 15).toInstant(zone)
|
||||
val length = if (random.nextInt(8) == 0) 0 else random.nextInt(1, 8) * 15
|
||||
testEvent(id = id, title = "T$id", start = from, end = from + length.minutes, response = response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RowSeating(
|
||||
val seated: List<Pair<Int, EventInstance>>,
|
||||
val hiddenEvents: List<EventInstance>,
|
||||
val hiddenCount: Int,
|
||||
)
|
||||
|
||||
/** MonthWeekRow's bars, pills and overflow dots, as the composable worked them out. */
|
||||
private fun rowSeating(week: MonthWeek, col: Int, d: LocalDate, laneCap: Int): RowSeating {
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
||||
val bars = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane to it.event }
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
val pills = pillsShown.mapIndexed { i, ev -> freeSlots[i] to ev }
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
val hiddenEvents = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event) }
|
||||
addAll(timed.drop(pillsShown.size))
|
||||
}
|
||||
return RowSeating((bars + pills).sortedBy { it.first }, hiddenEvents, hidden)
|
||||
}
|
||||
|
||||
private fun refLaneEvents(week: MonthWeek, col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
week.spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) byLane[span.lane] = span.event
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
week.timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> byLane[free[i]] = event }
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
private fun refChipAt(week: MonthWeek, col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in week.days.indices || lane !in 0 until laneCap) return null
|
||||
week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return week.timedByDay[week.days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
}
|
||||
|
||||
private fun refOverflowEvents(week: MonthWeek, col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val seatedLanes = week.spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
return buildList {
|
||||
week.spans.forEach { span ->
|
||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||
}
|
||||
addAll(week.timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pins each grid's page numbering to the formulas it shipped with: a pager
|
||||
* restores its page by number, so a change here moves the user to another date.
|
||||
*/
|
||||
class PageNumberingTest {
|
||||
|
||||
private val dates: List<LocalDate> = buildList {
|
||||
var d = LocalDate(1500, 1, 1)
|
||||
while (d < LocalDate(2500, 1, 1)) {
|
||||
add(d)
|
||||
d = d.plus(37, DateTimeUnit.DAY)
|
||||
}
|
||||
var e = LocalDate(1999, 12, 1)
|
||||
while (e < LocalDate(2000, 2, 1)) {
|
||||
add(e)
|
||||
e = e.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pages(count: Int): List<Int> =
|
||||
listOf(-7, -1, 0, 1, count / 2 - 3, count / 2 - 1, count / 2, count / 2 + 1, count - 2, count - 1, count, count + 5) +
|
||||
(0 until count step 997)
|
||||
|
||||
@Test
|
||||
fun `week pages keep their numbering for every first day`() {
|
||||
DayOfWeek.entries.forEach { firstDay ->
|
||||
val grid = DaysGrid.weeks(firstDay)
|
||||
assertThat(grid.dayCount).isEqualTo(7)
|
||||
assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT)
|
||||
pages(LEGACY_WEEK_COUNT).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(legacyWeekStart(page, firstDay))
|
||||
}
|
||||
dates.forEach { date ->
|
||||
assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-day pages keep their numbering for every count and phase`() {
|
||||
(1..7).forEach { count ->
|
||||
(-count..count * 2).forEach { phase ->
|
||||
val grid = DaysGrid(count, phase)
|
||||
assertThat(grid.pageCount).isEqualTo(LEGACY_DAYS_COUNT)
|
||||
pages(LEGACY_DAYS_COUNT).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(legacyDaysStart(page, count, phase))
|
||||
}
|
||||
dates.forEach { date ->
|
||||
assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase))
|
||||
}
|
||||
}
|
||||
dates.filter { it.year in 1950..2050 }.forEach { date ->
|
||||
val grid = DaysGrid.startingAt(date, count)
|
||||
assertThat(grid.startForPage(grid.pageFor(date))).isEqualTo(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `day pages keep their numbering`() {
|
||||
val grid = DaysGrid.days
|
||||
assertThat(grid.dayCount).isEqualTo(1)
|
||||
assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT)
|
||||
pages(LEGACY_DAY_COUNT).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(legacyDayStart(page))
|
||||
}
|
||||
dates.forEach { date ->
|
||||
assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date))
|
||||
}
|
||||
}
|
||||
|
||||
@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(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(DaysGrid.days).isEqualTo(DaysGrid.days)
|
||||
assertThat(DaysGrid.days.hashCode()).isEqualTo(DaysGrid.days.hashCode())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LEGACY_WEEK_COUNT = 20_000
|
||||
const val LEGACY_DAYS_COUNT = 40_000
|
||||
const val LEGACY_DAY_COUNT = 140_000
|
||||
val WEEK_EPOCH = LocalDate(2000, 1, 3)
|
||||
val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
fun LocalDate.weekStart(firstDay: DayOfWeek): LocalDate =
|
||||
minus(((dayOfWeek.ordinal - firstDay.ordinal) + 7) % 7, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyWeekStart(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
WEEK_EPOCH.weekStart(firstDay).plus((page - LEGACY_WEEK_COUNT / 2) * 7, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyWeekPage(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = WEEK_EPOCH.weekStart(firstDay).daysUntil(date.weekStart(firstDay))
|
||||
return (LEGACY_WEEK_COUNT / 2 + days / 7).coerceIn(0, LEGACY_WEEK_COUNT - 1)
|
||||
}
|
||||
|
||||
fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyDaysPage(date: LocalDate, count: Int, phase: Int): Int =
|
||||
(LEGACY_DAYS_COUNT / 2 + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, count))
|
||||
.coerceIn(0, LEGACY_DAYS_COUNT - 1)
|
||||
|
||||
fun legacyDayStart(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(page - LEGACY_DAY_COUNT / 2, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyDayPage(date: LocalDate): Int =
|
||||
(LEGACY_DAY_COUNT / 2 + DAYS_EPOCH.daysUntil(date)).coerceIn(0, LEGACY_DAY_COUNT - 1)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user