From a87ebc33b6b6a4d6f74dc2a88171b4e277eae855 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:18:01 +0200 Subject: [PATCH] Month widget: tap a day anywhere opens it, fit busy months into Glance's view limit (#386, #372) --- .../calendula/widget/month/MonthGrid.kt | 109 +++++++++++++ .../calendula/widget/month/MonthWidget.kt | 143 ++++-------------- .../calendula/widget/month/MonthGridTest.kt | 78 ++++++++++ 3 files changed, 214 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt new file mode 100644 index 0000000..3a8915e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -0,0 +1,109 @@ +package de.jeanlucmakiola.calendula.widget.month + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.month.MonthWeek +import de.jeanlucmakiola.calendula.ui.month.seating +import de.jeanlucmakiola.calendula.ui.month.spanAt +import kotlinx.datetime.LocalDate + +/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */ +internal const val MAX_LANES = 3 + +/** + * Glance gives each rendering 500 view ids and fails the whole widget past them + * (#372). The grid stays under this, leaving room for Glance's own wrappers. + */ +internal const val VIEW_BUDGET = 440 + +/** Views outside the week rows: the header row with its title and three icons, a spacer and the weekday row. */ +internal const val FIXED_VIEWS = 18 + +/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ +internal data class LaneCell(val event: EventInstance?, val cols: Int) + +/** A "+[count]" label one column wide, or — with a zero count — a gap [cols] wide. */ +internal data class OverflowCell(val count: Int, val cols: Int) + +/** + * Lane [lane] split into bars and the gaps between them, consecutive blank + * columns merged into one gap: the day-open taps come from the strip underneath, + * so a gap needs no per-column view and a mostly-empty lane collapses to a spacer. + */ +internal fun MonthWeek.laneCells(lane: Int, laneCap: Int): List { + val cells = mutableListOf() + var gap = 0 + var col = 0 + while (col < days.size) { + val seat = seating(col, laneCap).seated.firstOrNull { it.lane == lane } + if (seat == null) { + gap += 1 + col += 1 + continue + } + if (gap > 0) cells += LaneCell(null, gap) + gap = 0 + val cols = if (seat.bar) (spanAt(col, lane)?.endCol ?: col) - col + 1 else 1 + cells += LaneCell(seat.event, cols) + col += cols + } + if (gap > 0) cells += LaneCell(null, gap) + return cells +} + +/** How many lanes this week fills from the top under [laneCap]; empty lanes below are not drawn. */ +internal fun MonthWeek.usedLanes(laneCap: Int): Int = + days.indices.maxOfOrNull { col -> seating(col, laneCap).seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0 + +/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ +internal fun MonthWeek.hiddenPerDay(laneCap: Int): List = + days.indices.map { col -> seating(col, laneCap).hidden.size } + +/** The "+N" row as labels and merged gaps; empty when nothing is hidden. */ +internal fun overflowCells(hidden: List): List { + if (hidden.none { it > 0 }) return emptyList() + val cells = mutableListOf() + var gap = 0 + hidden.forEach { count -> + if (count == 0) { + gap += 1 + } else { + if (gap > 0) cells += OverflowCell(0, gap) + gap = 0 + cells += OverflowCell(count, 1) + } + } + if (gap > 0) cells += OverflowCell(0, gap) + return cells +} + +/** The views `WeekRow` emits for this week with [laneCap] lanes, counted the way it draws them. */ +internal fun MonthWeek.viewCount(laneCap: Int, today: LocalDate): Int { + val strips = 1 + days.size + val numbers = 1 + days.size + if (today in days) 2 else 0 + val lanes = (0 until usedLanes(laneCap)).sumOf { lane -> + 2 + laneCells(lane, laneCap).sumOf { if (it.event == null) 1 else 3 } + } + val overflow = overflowCells(hiddenPerDay(laneCap)).let { if (it.isEmpty()) 0 else 1 + it.size } + // The week's Box, its content Column and the spacer under the numbers. + return 3 + strips + numbers + lanes + overflow +} + +/** + * The lanes each week may draw so the grid fits [budget] views: the most + * expensive week gives up its lowest lane first, its events folding into "+N", + * until the whole month fits (#372). + */ +internal fun laneCaps( + weeks: List, + today: LocalDate, + budget: Int = VIEW_BUDGET - FIXED_VIEWS, +): List { + val caps = MutableList(weeks.size) { MAX_LANES } + val costs = MutableList(weeks.size) { weeks[it].viewCount(MAX_LANES, today) } + while (costs.sum() > budget) { + val busiest = weeks.indices.filter { caps[it] > 0 }.maxByOrNull { costs[it] } ?: break + caps[busiest] -= 1 + costs[busiest] = weeks[busiest].viewCount(caps[busiest], today) + } + return caps +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index e39a984..25a6fae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -86,8 +86,6 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") -/** Event rows (lanes) shown per week before the rest collapse into "+N". */ -private const val MAX_LANES = 3 private val LANE_HEIGHT = 14.dp private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp @@ -225,13 +223,15 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) + val caps = laneCaps(weeks, source.today) MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) WeekdayHeader(weekStart = source.weekStart, colW = colW) - weeks.forEach { week -> + weeks.forEachIndexed { i, week -> WeekRow( week = week, + laneCap = caps[i], currentMonth = ym.month, today = source.today, soften = source.soften, @@ -341,6 +341,7 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { @Composable private fun WeekRow( week: MonthWeek, + laneCap: Int, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -348,13 +349,13 @@ private fun WeekRow( modifier: GlanceModifier, ) { val context = LocalContext.current - val hidden = week.hiddenPerDay() + val overflow = overflowCells(week.hiddenPerDay(laneCap)) Box(modifier = modifier.fillMaxWidth()) { // Day-open targets, one full-height strip per column, sitting *under* the // content: a tap anywhere in a day still opens it, as in the app, without // every cell in the column carrying a click target of its own. The content - // above is not clickable, so touches fall through to this layer; only an - // event bar opts out to open its own detail. + // above is not clickable, event bars included, so touches fall through to + // this layer and a day opens wherever it is tapped (#386). Row(modifier = GlanceModifier.fillMaxSize()) { week.days.forEach { date -> Box( @@ -385,42 +386,21 @@ private fun WeekRow( // One lane row per event row. A multi-day span is a single Box spanning // its columns (colW * n) so it's connected with no seam and rounded ends. // Lanes past the last one this week fills emit nothing at all. - repeat(week.usedLanes()) { lane -> - LaneRow(week = week, lane = lane, soften = soften, colW = colW) + // The grid's view budget caps the lanes per week (#372); what doesn't + // fit folds into the "+N" row. Mirrored one to one by viewCount. + repeat(week.usedLanes(laneCap)) { lane -> + LaneRow(cells = week.laneCells(lane, laneCap), soften = soften, colW = colW) Spacer(GlanceModifier.height(1.dp)) } - if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) + if (overflow.isNotEmpty()) OverflowRow(cells = overflow, colW = colW) } } } -/** - * How many lanes this week actually fills, counting from the top. The grid draws - * that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views - * per day and most weeks leave the last two empty. - */ -private fun MonthWeek.usedLanes(): Int { - for (lane in MAX_LANES - 1 downTo 0) { - val filled = spans.any { it.lane == lane } || - days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null } - if (filled) return lane + 1 - } - return 0 -} - -/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ -private fun MonthWeek.hiddenPerDay(): List = days.mapIndexed { col, date -> - val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } - val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) - val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size) - ((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0) -} - /** * Open [date]'s day view rooted in the month view (so back returns to the month * grid) — the same target the in-app month grid uses when a day cell is tapped. - * Shared by every tappable part of a day column so, as in the app, a tap anywhere - * on a day opens it; only an event bar on top opts out to open its own detail. + * The only tap target in a day column, so a tap anywhere on a day opens it. */ private fun openDayAction(context: Context, date: LocalDate) = actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) @@ -459,50 +439,10 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: } } -/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ -private data class LaneCell(val event: EventInstance?, val cols: Int) - -/** - * A lane split into bars and the gaps between them, with consecutive blank columns - * merged into one gap: the week's day-open taps come from the strip underneath, so - * a gap needs no per-column view and a mostly-empty lane collapses to a spacer. - */ -private fun MonthWeek.laneCells(lane: Int): List { - val cells = mutableListOf() - var gap = 0 - var col = 0 - fun closeGap() { - if (gap > 0) cells += LaneCell(null, gap) - gap = 0 - } - while (col < 7) { - val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } - val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null - when { - span != null -> { - closeGap() - cells += LaneCell(span.event, span.endCol - col + 1) - col = span.endCol + 1 - } - timed != null -> { - closeGap() - cells += LaneCell(timed, 1) - col += 1 - } - else -> { - gap += 1 - col += 1 - } - } - } - closeGap() - return cells -} - @Composable -private fun LaneRow(week: MonthWeek, lane: Int, soften: Boolean, colW: Dp) { +private fun LaneRow(cells: List, soften: Boolean, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { - week.laneCells(lane).forEach { cell -> + cells.forEach { cell -> if (cell.event == null) { Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) } else { @@ -526,19 +466,7 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { modifier = GlanceModifier .width(width) .height(LANE_HEIGHT) - .padding(horizontal = 1.dp) - // Tap an event bar to open its detail, rooted in the month view. - .clickable( - actionStartActivity( - MainActivity.openEventIntent( - context = context, - eventId = event.eventId, - beginMillis = event.start.toEpochMilliseconds(), - endMillis = event.end.toEpochMilliseconds(), - source = CalendarView.Month, - ), - ), - ), + .padding(horizontal = 1.dp), ) { Box( modifier = GlanceModifier @@ -563,40 +491,23 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { /** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ @Composable -private fun OverflowRow(hidden: List, colW: Dp) { +private fun OverflowRow(cells: List, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { - var gap = 0 - hidden.forEach { count -> - if (count == 0) { - gap += 1 - return@forEach + cells.forEach { cell -> + if (cell.count == 0) { + Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) + } else { + Text( + text = "+${cell.count}", + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), + modifier = GlanceModifier.width(colW).padding(start = 3.dp), + ) } - if (gap > 0) { - Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) - gap = 0 - } - Text( - text = "+$count", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.width(colW).padding(start = 3.dp), - ) } - if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) } } -/** The timed single-day event that fills lane [lane] on day [col], if any. */ -private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? { - val occupied = week.spans - .filter { col in it.startCol..it.endCol && it.lane < MAX_LANES } - .map { it.lane } - .toSet() - val freeSlots = (0 until MAX_LANES).filter { it !in occupied } - val timed = week.timedByDay[date].orEmpty() - return timed.getOrNull(freeSlots.indexOf(lane)) -} - @Composable private fun PermissionMessage() { val context = LocalContext.current diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt new file mode 100644 index 0000000..f2751e5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -0,0 +1,78 @@ +package de.jeanlucmakiola.calendula.widget.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.timedEvent +import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +/** The month widget's view budget (#372). */ +class MonthGridTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + private val today = LocalDate(2026, 7, 15) + + /** July 2026 with [perDay] timed events on every day of the grid. */ + private fun weeks(perDay: Int): List { + val first = LocalDate(2026, 6, 29) + var id = 0L + val events = (0 until 42).flatMap { offset -> + val day = first.plus(offset, DateTimeUnit.DAY) + (1..perDay).map { hour -> + timedEvent(day, hour, zone = zone, id = ++id, title = "E$id", color = 0xFFF44336.toInt()) + } + } + return layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) + } + + @Test + fun `a quiet month keeps every lane`() { + val weeks = weeks(perDay = 1) + assertThat(laneCaps(weeks, today)).containsExactlyElementsIn(List(weeks.size) { MAX_LANES }) + } + + @Test + fun `a full month folds lanes until it fits the budget`() { + val weeks = weeks(perDay = 5) + val fullCost = weeks.sumOf { it.viewCount(MAX_LANES, today) } + assertThat(fullCost).isGreaterThan(VIEW_BUDGET - FIXED_VIEWS) + + val caps = laneCaps(weeks, today) + val cost = weeks.indices.sumOf { weeks[it].viewCount(caps[it], today) } + assertThat(cost).isAtMost(VIEW_BUDGET - FIXED_VIEWS) + assertThat(caps.any { it < MAX_LANES }).isTrue() + } + + @Test + fun `folded events are counted in the overflow`() { + val week = weeks(perDay = 5)[1] + val hidden = week.hiddenPerDay(laneCap = 1) + assertThat(hidden).containsExactlyElementsIn(List(7) { 4 }) + assertThat(week.usedLanes(laneCap = 1)).isEqualTo(1) + } + + @Test + fun `a lane of single-day events is one cell per day`() { + val cells = weeks(perDay = 1)[1].laneCells(lane = 0, laneCap = MAX_LANES) + assertThat(cells).hasSize(7) + assertThat(cells.all { it.event != null && it.cols == 1 }).isTrue() + } + + @Test + fun `overflow gaps are merged`() { + assertThat(overflowCells(listOf(0, 0, 2, 0, 0, 0, 1))).containsExactly( + OverflowCell(0, 2), + OverflowCell(2, 1), + OverflowCell(0, 3), + OverflowCell(1, 1), + ).inOrder() + assertThat(overflowCells(List(7) { 0 })).isEmpty() + } +}