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 6155f20..2675e6e 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 { 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, 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 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 34441c4..3b1bc80 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,19 +39,6 @@ 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. @@ -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 { - 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) { - // 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() } 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 572da8f..0374ab3 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).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 {