diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index faa39ce..6155f20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid( @Composable private fun SplitDayCell( date: LocalDate, - events: List, + events: List, /** 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, hidden: Int, dark: Boolean) { +private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - events.forEach { event -> + events.forEach { seated -> // 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. @@ -1415,11 +1415,24 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, // 5dp for the whole transition however far its bar had travelled, and // 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. Box( modifier = Modifier - .morphBounds(MonthMorphKey.Event(date, event.instanceId)) + .then( + if (seated.anchored) { + Modifier.morphBounds( + MonthMorphKey.Event(date, seated.event.instanceId), + ) + } else { + Modifier + }, + ) .size(SPLIT_DOT_SIZE) - .background(eventFill(event.color, dark, soften), CircleShape), + .background(eventFill(seated.event.color, dark, soften), CircleShape), ) } if (hidden > 0) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 3b1bc80..34441c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,6 +39,19 @@ data class MonthWeek( val countByDay: Map, ) +/** + * 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. @@ -53,16 +66,19 @@ data class MonthWeek( * 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 { - val byLane = arrayOfNulls(laneCap) +fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) spans.forEach { span -> if (span.lane < laneCap && col in span.startCol..span.endCol) { - byLane[span.lane] = span.event + // 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) } } 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]] = event + byLane[free[i]] = SeatedEvent(event, anchored = true) } return byLane.filterNotNull() } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt index 0374ab3..572da8f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -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)) + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).map { it.event }) .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)) + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event }) .containsExactly(monday) } @@ -80,11 +80,48 @@ class LaneEventsTest { (1..3).forEach { col -> val day = LocalDate(2026, 7, 6 + col) - assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + assertThat(week.laneEvents(col, day, laneCap = 3).map { it.event }).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`() { @@ -92,7 +129,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, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event }) .containsExactly(first, second) .inOrder() } @@ -104,7 +141,7 @@ class LaneEventsTest { val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) assertThat(seated).hasSize(3) - assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(seated.map { it.event }).containsExactlyElementsIn(events.take(3)).inOrder() assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) } @@ -119,7 +156,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) + .containsNoneIn(seated.map { it.event }) } private companion object {