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 43 additions and 80 deletions
Showing only changes of commit 7a05fc3ac1 - Show all commits

View File

@@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid(
@Composable
private fun SplitDayCell(
date: LocalDate,
events: List<SeatedEvent>,
events: List<EventInstance>,
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
hidden: Int,
isToday: Boolean,
@@ -1398,14 +1398,14 @@ private fun SplitDayCell(
* bar with no dot to grow out of.
*/
@Composable
private fun SplitDots(date: LocalDate, events: List<SeatedEvent>, hidden: Int, dark: Boolean) {
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
if (events.isEmpty()) return
val soften = LocalSoftenColors.current
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
events.forEach { seated ->
events.forEach { event ->
// The dot keeps its own circle and the bar its 4dp corners; they
// cross-fade inside shared bounds, and at the dot's size the two
// radii are a fraction of a pixel apart.
@@ -1416,23 +1416,14 @@ private fun SplitDots(date: LocalDate, events: List<SeatedEvent>, hidden: Int, d
// only snapped to full width once the transition ended — the growth
// never happened, it was clamped away.
//
// Only tagged where a bar actually meets it. A dot on the middle of a
// multi-day run has nothing to travel to, and tagging it regardless
// gave it no bounds to start from, so it flew in from the top of the
// grid rather than fading where it stands.
// *Every* dot is tagged, including those in the middle of a multi-day
// run: the expanded grid gives each covered column its own anchor
// inside the bar, so each one has a real place to come from.
Box(
modifier = Modifier
.then(
if (seated.anchored) {
Modifier.morphBounds(
MonthMorphKey.Event(date, seated.event.instanceId),
)
} else {
Modifier
},
)
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE)
.background(eventFill(seated.event.color, dark, soften), CircleShape),
.background(eventFill(event.color, dark, soften), CircleShape),
)
}
if (hidden > 0) {
@@ -1777,6 +1768,31 @@ private fun MonthWeekRow(
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
// One invisible slice of the bar per further column it
// covers. A multi-day event has a dot on every day but
// only one bar, so those other dots had nothing to travel
// to and sat still while the rest of the grid moved.
//
// These give each of them a real place inside the bar to
// come out of and go back into, at its own column — so a
// dot drops out of the bar above it rather than appearing
// from nowhere, or from the top of the grid, which is what
// an untagged dot and a partnerless tag respectively did.
for (c in (span.startCol + 1)..span.endCol) {
Box(
Modifier
.offset(x = colW * c, y = EVENT_ROW_HEIGHT * span.lane)
.morphBounds(
MonthMorphKey.Event(
date = week.days[c],
instanceId = span.event.instanceId,
),
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
}
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a

View File

@@ -39,19 +39,6 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/**
* One event seated in a lane of one day, and whether *this* day is where the
* expanded grid draws it.
*
* A multi-day event has a dot on every day it covers but only one bar, drawn from
* where it starts in that week. So only the start day's dot has something to
* become; the rest are [anchored] = false and must not be tagged for the morph at
* all. Tagging them anyway gives Compose a shared element that enters with no
* partner and therefore no bounds to start from, and it flies in from the layout
* origin — the top of the grid — instead of quietly fading where it stands (#53).
*/
data class SeatedEvent(val event: EventInstance, val anchored: Boolean)
/**
* The events occupying each lane of [day] — column [col] of this week — in lane
* order, capped at [laneCap] lanes.
@@ -66,19 +53,16 @@ data class SeatedEvent(val event: EventInstance, val anchored: Boolean)
* 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<SeatedEvent> {
val byLane = arrayOfNulls<SeatedEvent>(laneCap)
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) {
// A bar is drawn once per row, from the column it starts in — so that
// is the only day of it whose dot has a counterpart to travel to.
byLane[span.lane] = SeatedEvent(span.event, anchored = col == span.startCol)
byLane[span.lane] = span.event
}
}
val free = (0 until laneCap).filter { byLane[it] == null }
// A single-day event is drawn on its own day, so it is always anchored there.
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
byLane[free[i]] = SeatedEvent(event, anchored = true)
byLane[free[i]] = event
}
return byLane.filterNotNull()
}

View File

@@ -58,7 +58,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).map { it.event })
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
.containsExactly(bar, meeting)
.inOrder()
}
@@ -69,7 +69,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).map { it.event })
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
.containsExactly(monday)
}
@@ -80,48 +80,11 @@ class LaneEventsTest {
(1..3).forEach { col ->
val day = LocalDate(2026, 7, 6 + col)
assertThat(week.laneEvents(col, day, laneCap = 3).map { it.event }).containsExactly(bar)
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
}
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
}
/**
* Only the day a bar is drawn from has a counterpart to morph into. Tagging
* the other days' dots too gave them a shared element with no partner and so
* no bounds to start from, and they flew in from the top of the grid.
*/
@Test
fun `only the day a multi-day bar starts on is anchored`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val week = rowOfJuly6(listOf(bar))
val anchored = (1..3).map { col ->
week.laneEvents(col, LocalDate(2026, 7, 6 + col), laneCap = 3).single().anchored
}
assertThat(anchored).containsExactly(true, false, false).inOrder()
}
/** A bar carried in from the previous week restarts at column 0 of this row. */
@Test
fun `a bar continuing into the row anchors on its first day here`() {
val bar = allDay(LocalDate(2026, 7, 3), LocalDate(2026, 7, 8), id = 1L)
val week = rowOfJuly6(listOf(bar))
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored)
.isTrue()
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).single().anchored)
.isFalse()
}
@Test
fun `a single-day event is always anchored on its own day`() {
val meeting = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)
val week = rowOfJuly6(listOf(meeting))
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored)
.isTrue()
}
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
@Test
fun `events sharing a colour each keep their own lane`() {
@@ -129,7 +92,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).map { it.event })
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
.containsExactly(first, second)
.inOrder()
}
@@ -141,7 +104,7 @@ class LaneEventsTest {
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
assertThat(seated).hasSize(3)
assertThat(seated.map { it.event }).containsExactlyElementsIn(events.take(3)).inOrder()
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
}
@@ -156,7 +119,7 @@ class LaneEventsTest {
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
assertThat(seated).hasSize(3)
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
.containsNoneIn(seated.map { it.event })
.containsNoneIn(seated)
}
private companion object {