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 41a4d8f..0bd8290 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 @@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import kotlin.math.abs import kotlin.time.Clock +import kotlin.time.Instant import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -1277,7 +1278,7 @@ internal fun SplitMonthGrid( SplitDayCell( date = day, events = seated, - hidden = (week.countByDay[day] ?: 0) - seated.size, + hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS), isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -1318,7 +1319,7 @@ private fun SplitDayCell( date: LocalDate, events: List, /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ - hidden: Int, + hidden: List, isToday: Boolean, isSelected: Boolean, inMonth: Boolean, @@ -1436,9 +1437,15 @@ 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: List, + dark: Boolean, +) { if (events.isEmpty()) return val soften = LocalSoftenColors.current + val dimCutoff = LocalDimCutoff.current Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, @@ -1461,18 +1468,21 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, modifier = Modifier .morphBounds(MonthMorphKey.Event(date, event.instanceId)) .size(SPLIT_DOT_SIZE) + .alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f) .background(eventFill(event.color, dark, soften), CircleShape), ) } - if (hidden > 0) { + if (hidden.isNotEmpty()) { // Tagged, not lifted: this count and the expanded grid's dot row are // the same marker on the same day, so it travels with its cell like // everything else rather than riding above the grid on its own layer. Text( - text = "+$hidden", + text = "+${hidden.size}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)), + modifier = Modifier + .morphBounds(MonthMorphKey.Overflow(date)) + .alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f), ) } } @@ -1870,15 +1880,15 @@ private fun MonthWeekRow( } val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size if (hidden > 0) { - val hiddenColors = buildList { + val hiddenEvents = buildList { week.spans .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } - .forEach { add(it.event.color) } - timed.drop(pillsShown.size).forEach { add(it.color) } - }.distinct().take(3) + .forEach { add(it.event) } + addAll(timed.drop(pillsShown.size)) + } OverflowDots( - colors = hiddenColors, - extra = hidden - hiddenColors.size, + events = hiddenEvents, + total = hidden, dark = dark, modifier = Modifier .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) @@ -2058,37 +2068,52 @@ private fun MonthBar( } } -/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */ +/** + * Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest. + * + * A dot stands for every hidden event sharing its colour, so it dims only once + * all of them have ended; the "+N" dims once the whole overflow has (#79). + */ @Composable private fun OverflowDots( - colors: List, - extra: Int, + events: List, + total: Int, dark: Boolean, modifier: Modifier = Modifier, ) { val soften = LocalSoftenColors.current + val dimCutoff = LocalDimCutoff.current + val byColor = events.groupBy { it.color } + val dots = byColor.keys.take(3) Row( modifier = modifier.height(EVENT_ROW_HEIGHT), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - colors.forEach { argb -> + dots.forEach { argb -> Box( modifier = Modifier .size(6.dp) + .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .background(eventFill(argb, dark, soften), CircleShape), ) } + val extra = total - dots.size if (extra > 0) { Text( text = "+$extra", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f), ) } } } +/** True when dimming is on and every one of [events] is already over. */ +private fun allEnded(events: List, dimCutoff: Instant?): Boolean = + dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) } + @Composable private fun MonthGridLoading() { val shape = MaterialTheme.shapes.medium 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..b7f5f6a 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 @@ -67,6 +67,24 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + 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)) + } +} + /** * State for the continuous style (#38): a vertical stream of *self-contained* * months rather than one undifferentiated run of weeks. Each month is keyed by 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..a64ff01 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 @@ -122,6 +122,42 @@ class LaneEventsTest { .containsNoneIn(seated) } + @Test + 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) + + assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder() + assertThat(seated + overflow).containsExactlyElementsIn(events) + assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day]) + } + + @Test + fun `a bar beyond the cap overflows on every day it covers`() { + val bars = (0 until 4).map { + allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L) + } + val week = rowOfJuly6(bars) + 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)) + .containsExactlyElementsIn(parked) + } + } + + @Test + 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() + } + private companion object { const val BLUE = 0xFF3366CC.toInt() const val RED = 0xFFCC3333.toInt()