Month widget: tap a day anywhere opens it, fit busy months into Glance's view limit (#386, #372)

This commit is contained in:
2026-10-21 18:18:01 +02:00
parent 3471447ab6
commit a87ebc33b6
3 changed files with 214 additions and 116 deletions
@@ -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<LaneCell> {
val cells = mutableListOf<LaneCell>()
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<Int> =
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<Int>): List<OverflowCell> {
if (hidden.none { it > 0 }) return emptyList()
val cells = mutableListOf<OverflowCell>()
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<MonthWeek>,
today: LocalDate,
budget: Int = VIEW_BUDGET - FIXED_VIEWS,
): List<Int> {
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
}
@@ -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<String> {
@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<Int> = 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<LaneCell> {
val cells = mutableListOf<LaneCell>()
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<LaneCell>, 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<Int>, colW: Dp) {
private fun OverflowRow(cells: List<OverflowCell>, 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
@@ -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<de.jeanlucmakiola.calendula.ui.month.MonthWeek> {
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()
}
}