Month widget: one lane shows bars and per-day counts (#491)

This commit is contained in:
makiolaj committed 2026-11-09 19:17:57 +01:00
1 parent 726dbbbaf3
commit dec2d1cb05
3 files changed
+60 -14

No files matched your search

@@ -36,16 +36,24 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): L
return cells
}
/** What a cell of the single mixed lane shows: an event's bar, or a day's event count. */
internal sealed interface LaneItem {
data class Bar(val event: EventInstance) : LaneItem
data class Count(val count: Int) : LaneItem
}
/**
* One week of the grid seated with [laneCap] lanes, drawn [compact] when the
* row is too short for a full one. [showsOverflow] is off when the week has no
* room for the "+N" row.
* room for the "+N" row; a [mixed] single lane then shows each day's count in
* place of its bar where the day has more than fits (#491).
*/
internal class WeekLayout(
val week: MonthWeek,
val laneCap: Int,
val showsOverflow: Boolean = true,
val compact: Boolean = false,
val mixed: Boolean = false,
) {
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
@@ -61,6 +69,16 @@ internal class WeekLayout(
}
}
/** The single lane of a [mixed] week, bars cut short where a day shows its count instead; null otherwise. */
val mixedLane: List<GridCell<LaneItem>>? = if (!mixed) null else mergeGaps(week.days.size) { col ->
val day = seating[col]
if (day.hidden.isNotEmpty()) return@mergeGaps GridCell(LaneItem.Count(day.seated.size + day.hidden.size), 1)
val seat = day.seated.firstOrNull() ?: return@mergeGaps null
val spanEnd = if (seat.bar) week.spanAt(col, seat.lane)?.endCol ?: col else col
val end = (col + 1..spanEnd).firstOrNull { seating[it].hidden.isNotEmpty() }?.minus(1) ?: spanEnd
GridCell(LaneItem.Bar(seat.event), end - col + 1)
}
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
if (hidden.none { it > 0 }) emptyList()
@@ -85,12 +103,14 @@ internal fun lanesFitting(weekHeight: Dp, fontScale: Float): LaneFit {
/**
* Seats each week in [fit]'s lanes. A week whose events need more gives its
* last lane to the "+N" row instead. A compact lane has no room for that row,
* so it keeps its bars (#491).
* last lane to the "+N" row instead. A single lane has no room for that row,
* so it mixes bars and per-day counts (#491).
*/
internal fun fitWeeks(weeks: List<MonthWeek>, fit: LaneFit): List<WeekLayout> = weeks.map { week ->
val lanes = fit.lanes
if (lanes == 0 || fit.compact) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact)
if (lanes <= 1) {
return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact, mixed = lanes == 1)
}
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
}
@@ -91,6 +91,21 @@ private fun weekViews(
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
}
layout.mixedLane?.let { cells ->
val lane = laneViews(context, layout.compact)
cells.forEach { cell ->
lane.addView(
R.id.lane,
when (val item = cell.item) {
null -> gap(context, cell.cols)
is LaneItem.Bar -> bar(context, item.event, cell.cols, soften, mode)
is LaneItem.Count -> more(context, item.count, colors)
},
)
}
week.addView(R.id.week_lanes, lane)
return week
}
layout.lanes.forEach { cells ->
val lane = laneViews(context, layout.compact)
cells.forEach { cell ->
@@ -106,10 +121,7 @@ private fun weekViews(
if (count == null) {
lane.addView(R.id.lane, gap(context, cell.cols))
} else {
val more = RemoteViews(context.packageName, R.layout.widget_month_more)
more.setTextViewText(R.id.more, "+$count")
more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
lane.addView(R.id.lane, more)
lane.addView(R.id.lane, more(context, count, colors))
}
}
week.addView(R.id.week_lanes, lane)
@@ -117,6 +129,12 @@ private fun weekViews(
return week
}
private fun more(context: Context, count: Int, colors: GridColors) =
RemoteViews(context.packageName, R.layout.widget_month_more).apply {
setTextViewText(R.id.more, "+$count")
setTextColor(context, R.id.more, colors.onSurfaceVariant)
}
private fun laneViews(context: Context, compact: Boolean = false) =
RemoteViews(context.packageName, if (compact) R.layout.widget_month_lane_compact else R.layout.widget_month_lane)
.apply { removeAllViews(R.id.lane) }
@@ -75,16 +75,24 @@ class MonthGridTest {
}
@Test
fun `a single full lane still folds a busy week into the count`() {
fun `a single lane shows counts on busy days`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1))
assertThat(layouts.all { it.laneCap == 0 && it.showsOverflow && it.overflow.isNotEmpty() }).isTrue()
assertThat(layouts.all { it.mixedLane == List(7) { GridCell<LaneItem>(LaneItem.Count(5), 1) } }).isTrue()
}
@Test
fun `a compact lane keeps its bars in a busy week`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1, compact = true))
assertThat(layouts.all { it.laneCap == 1 && it.lanes.size == 1 && it.compact }).isTrue()
assertThat(layouts.none { it.showsOverflow }).isTrue()
fun `a single lane keeps bars on days that fit`() {
val lone = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 1, title = "Lone")
val busy = (2L..4L).map { timedEvent(monday.plus(3, DateTimeUnit.DAY), it.toInt() + 8, zone = zone, id = it, title = "B$it") }
val week = fitWeeks(weeks(listOf(lone) + busy), LaneFit(1, compact = true))[1]
assertThat(week.compact).isTrue()
assertThat(week.mixedLane).containsExactly(
GridCell<LaneItem>(null, 1),
GridCell<LaneItem>(LaneItem.Bar(lone), 1),
GridCell<LaneItem>(null, 1),
GridCell<LaneItem>(LaneItem.Count(3), 1),
GridCell<LaneItem>(null, 3),
).inOrder()
}
@Test