Compare commits

...
5 changed files with 140 additions and 27 deletions

No files matched your search

@@ -36,11 +36,25 @@ 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. [showsOverflow] is off when
* the week has no room for even the "+N" row.
* 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; 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) {
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) }
/** Lanes filled from the top; empty lanes below are not drawn. */
@@ -55,6 +69,16 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
}
}
/** 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(week.countByDay[week.days[col]] ?: 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()
@@ -62,19 +86,31 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
}
}
/**
* Event lanes a week row [weekHeight] tall holds under its day number, held to
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
*/
internal fun lanesFitting(weekHeight: Dp): Int =
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
/** How many lanes a week row holds, and whether they're the [compact] height. */
internal data class LaneFit(val lanes: Int, val compact: Boolean = false)
/**
* Seats each week in [lanes] lanes. A week whose events need more gives its
* last lane to the "+N" row instead.
* Event lanes a week row [weekHeight] tall holds under its day number, held to
* [MAX_LANES] so a tall widget's payload stays bounded (#214). A row too short
* for one full lane still gets a compact one when its text fits at [fontScale] (#491).
*/
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
internal fun lanesFitting(weekHeight: Dp, fontScale: Float): LaneFit {
val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP
val full = (room / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
val compactFits = room >= COMPACT_LANE_ROW_DP && fontScale <= COMPACT_MAX_FONT_SCALE
return if (full == 0 && compactFits) LaneFit(1, compact = true) else LaneFit(full)
}
/**
* Seats each week in [fit]'s lanes. A week whose events need more gives its
* 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 <= 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)
}
@@ -85,4 +121,7 @@ internal const val MAX_LANES = 8
private const val DAY_ROW_DP = 18f
private const val LANES_TOP_DP = 2f
private const val LANE_ROW_DP = 15f
/** Matches `widget_month_lane_compact`'s height; its 9sp text clips past [COMPACT_MAX_FONT_SCALE]. */
private const val COMPACT_LANE_ROW_DP = 12f
private const val COMPACT_MAX_FONT_SCALE = 1f
private const val UNCAPPED_LANES = 64
@@ -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)
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,8 +129,15 @@ private fun weekViews(
return week
}
private fun laneViews(context: Context) =
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
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) }
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
@@ -254,7 +254,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
val layouts = fitWeeks(weeks, lanesFitting(weekHeight, fontScale))
val mode = LocalWidgetThemeMode.current
val colors = GridColors(
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/lane"
android:layout_width="match_parent"
android:layout_height="12dp"
android:orientation="horizontal"
android:paddingBottom="1dp"
android:weightSum="7" />
@@ -42,33 +42,80 @@ class MonthGridTest {
@Test
fun `a week that fits keeps every lane`() {
val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
val layouts = fitWeeks(busyWeeks(perDay = 2), LaneFit(3))
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
}
@Test
fun `a week that doesn't fit gives its last lane to the overflow`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(3))
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
}
@Test
fun `a taller week fits more lanes`() {
assertThat(lanesFitting(19.dp)).isEqualTo(0)
assertThat(lanesFitting(50.dp)).isEqualTo(2)
assertThat(lanesFitting(100.dp)).isEqualTo(5)
assertThat(lanesFitting(19.dp, fontScale = 1f)).isEqualTo(LaneFit(0))
assertThat(lanesFitting(50.dp, fontScale = 1f)).isEqualTo(LaneFit(2))
assertThat(lanesFitting(100.dp, fontScale = 1f)).isEqualTo(LaneFit(5))
}
@Test
fun `a row too short for a full lane gets a compact one`() {
assertThat(lanesFitting(34.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true))
assertThat(lanesFitting(32.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true))
assertThat(lanesFitting(31.dp, fontScale = 1f)).isEqualTo(LaneFit(0))
assertThat(lanesFitting(35.dp, fontScale = 1f)).isEqualTo(LaneFit(1))
}
@Test
fun `no compact lane at a large font scale`() {
assertThat(lanesFitting(34.dp, fontScale = 1.1f)).isEqualTo(LaneFit(0))
}
@Test
fun `a single lane shows counts on busy days`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1))
assertThat(layouts.all { it.mixedLane == List(7) { GridCell<LaneItem>(LaneItem.Count(5), 1) } }).isTrue()
}
@Test
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
fun `a single lane cuts a multi-day bar around a busy day`() {
val trip = allDayEvent(monday, monday.plus(3, DateTimeUnit.DAY), id = 1, title = "Trip")
val busy = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 2, title = "Busy")
val week = fitWeeks(weeks(listOf(trip, busy)), LaneFit(1))[1]
assertThat(week.mixedLane).containsExactly(
GridCell<LaneItem>(LaneItem.Bar(trip), 1),
GridCell<LaneItem>(LaneItem.Count(2), 1),
GridCell<LaneItem>(LaneItem.Bar(trip), 2),
GridCell<LaneItem>(null, 3),
).inOrder()
}
@Test
fun `lanes stop at the cap however tall the week`() {
assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
assertThat(lanesFitting(1000.dp, fontScale = 1f)).isEqualTo(LaneFit(MAX_LANES))
}
@Test
fun `a week with no room for a lane draws no overflow row either`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0)
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(0))
assertThat(layouts.none { it.showsOverflow }).isTrue()
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
}