Month widget: count Glance's real view ids per Android version, test the budget on device (#372)
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ExperimentalGlanceApi
|
||||
import androidx.glance.appwidget.compose
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Renders the month widget for a packed month through Glance's own translator
|
||||
* and counts what it inflates (#372): the view-id model in [WeekLayout] has to
|
||||
* match what Glance really builds, or a busy month fails with "too many views".
|
||||
*/
|
||||
@OptIn(ExperimentalGlanceApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MonthWidgetViewBudgetTest {
|
||||
|
||||
@get:Rule
|
||||
val permissionRule: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private val context = instrumentation.targetContext
|
||||
private var calendarId = -1L
|
||||
|
||||
private fun Uri.asSyncAdapter(): Uri = buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
.build()
|
||||
|
||||
@Before
|
||||
fun packTheMonth() {
|
||||
val calendar = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
put(CalendarContract.Calendars.NAME, ACCOUNT)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
|
||||
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
}
|
||||
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
|
||||
calendarId = ContentUris.parseId(requireNotNull(uri))
|
||||
|
||||
val zone = ZoneId.systemDefault()
|
||||
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
|
||||
val events = (0 until 49).flatMap { offset ->
|
||||
val day = first.plusDays(offset.toLong())
|
||||
(0 until EVENTS_PER_DAY).map { i ->
|
||||
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.TITLE, "Busy $i")
|
||||
put(CalendarContract.Events.DTSTART, start)
|
||||
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
if (calendarId < 0) return
|
||||
context.contentResolver.delete(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking {
|
||||
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) {
|
||||
val views = MonthWidget().compose(context, size = size)
|
||||
var total = 0
|
||||
var texts = 0
|
||||
instrumentation.runOnMainSync {
|
||||
val root = views.apply(context, FrameLayout(context))
|
||||
fun walk(view: View) {
|
||||
total += 1
|
||||
if (view is TextView) texts += 1
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET")
|
||||
// The grid itself rendered, not Glance's error layout: every day number is there.
|
||||
assertThat(texts).isAtLeast(35)
|
||||
assertThat(total).isAtMost(GLANCE_VIEW_IDS)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MonthWidgetBudget"
|
||||
const val ACCOUNT = "calendula-widget-budget-test"
|
||||
const val EVENTS_PER_DAY = 6
|
||||
const val GLANCE_VIEW_IDS = 499
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.os.Build
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.month.DaySeating
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.seating
|
||||
import de.jeanlucmakiola.calendula.ui.month.spanAt
|
||||
@@ -10,100 +12,133 @@ import kotlinx.datetime.LocalDate
|
||||
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.
|
||||
* View ids one rendering may spend. Glance has 499 and fails the whole widget
|
||||
* past them (#372); staying well under also keeps a busy month's payload, sent
|
||||
* once per host size, clear of the binder limit #214 ran into.
|
||||
*/
|
||||
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)
|
||||
internal const val VIEW_BUDGET = 400
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* What views cost in Glance 1.2's view ids. A clickable view that isn't a
|
||||
* Button is wrapped in a Box sized like it, with a ripple Image and the view
|
||||
* itself filling it. Below Android 12 a view with a fixed width or height is
|
||||
* inflated into a sizing frame first, which takes a second id.
|
||||
*/
|
||||
internal fun MonthWeek.laneCells(lane: Int, laneCap: Int): List<LaneCell> {
|
||||
val cells = mutableListOf<LaneCell>()
|
||||
internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) {
|
||||
val plain = 1
|
||||
val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1
|
||||
val clickablePlain = plain + 2
|
||||
val clickableSized = sized + 2
|
||||
|
||||
/**
|
||||
* `MonthWidgetBody`'s views outside the week rows: its Column, the header
|
||||
* row with three sized clickable icon boxes, their sized icons and the
|
||||
* clickable title, the spacer, and the weekday row of sized labels.
|
||||
*/
|
||||
val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized
|
||||
}
|
||||
|
||||
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
|
||||
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
|
||||
|
||||
/**
|
||||
* The seven columns as cells and the gaps between them, consecutive empty
|
||||
* columns merged into one: the day-open taps come from the strip underneath,
|
||||
* so a gap needs no per-column view. [cellAt] gives what starts at a column
|
||||
* and how many columns it covers.
|
||||
*/
|
||||
internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
|
||||
val cells = mutableListOf<GridCell<T>>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
while (col < days.size) {
|
||||
val seat = seating(col, laneCap).seated.firstOrNull { it.lane == lane }
|
||||
if (seat == null) {
|
||||
while (col < columns) {
|
||||
val cell = cellAt(col)
|
||||
if (cell == null) {
|
||||
gap += 1
|
||||
col += 1
|
||||
continue
|
||||
}
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
if (gap > 0) cells += GridCell(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
|
||||
cells += cell
|
||||
col += cell.cols
|
||||
}
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
if (gap > 0) cells += GridCell(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
|
||||
/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
|
||||
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
|
||||
|
||||
/** 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 }
|
||||
/** Lanes filled from the top; empty lanes below are not drawn. */
|
||||
val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
|
||||
|
||||
/** 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)
|
||||
/** Each drawn lane as bars and merged gaps. */
|
||||
val lanes: List<List<GridCell<EventInstance>>> = List(usedLanes) { lane ->
|
||||
mergeGaps(week.days.size) { col ->
|
||||
val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null
|
||||
val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col
|
||||
GridCell(seat.event, endCol - col + 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 }
|
||||
/** 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()
|
||||
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The view ids `WeekRow` spends on this week, counted the way it draws it:
|
||||
* the week Box, the strip Row and its seven sized clickable strips, the
|
||||
* content Column, the sized day-number row and its sized numbers (today's
|
||||
* pill adds a sized Box around a plain Text), the sized spacer, each lane's
|
||||
* Row and sized spacer with sized gaps and bars (a sized Box around a
|
||||
* filling Box and Text), and the "+N" Row of sized cells.
|
||||
*/
|
||||
fun viewIds(today: LocalDate, ids: ViewIds): Int {
|
||||
val days = week.days.size
|
||||
val strips = ids.plain + days * ids.clickableSized
|
||||
val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0
|
||||
val lanes = lanes.sumOf { cells ->
|
||||
ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain }
|
||||
}
|
||||
val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized
|
||||
return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow
|
||||
}
|
||||
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).
|
||||
* Seats each week so the whole grid fits [budget] view ids (#372). Each round
|
||||
* folds the lowest lanes of whichever week saves the most ids per lane given
|
||||
* up, their events joining "+N", until the month fits.
|
||||
*/
|
||||
internal fun laneCaps(
|
||||
internal fun layoutWeeks(
|
||||
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) }
|
||||
ids: ViewIds = ViewIds(),
|
||||
budget: Int = VIEW_BUDGET - ids.fixed,
|
||||
): List<WeekLayout> {
|
||||
val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList()
|
||||
val costs = layouts.map { it.viewIds(today, ids) }.toMutableList()
|
||||
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)
|
||||
var best: Triple<Int, WeekLayout, Int>? = null
|
||||
var bestRate = 0.0
|
||||
layouts.forEachIndexed { i, layout ->
|
||||
for (cap in layout.laneCap - 1 downTo 0) {
|
||||
val folded = WeekLayout(layout.week, cap)
|
||||
val cost = folded.viewIds(today, ids)
|
||||
val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap)
|
||||
if (rate > bestRate) {
|
||||
bestRate = rate
|
||||
best = Triple(i, folded, cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (i, folded, cost) = best ?: break
|
||||
layouts[i] = folded
|
||||
costs[i] = cost
|
||||
}
|
||||
return caps
|
||||
return layouts
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ class MonthWidget : GlanceAppWidget() {
|
||||
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||
* Exact costs one serialised copy per host size, which is affordable now that
|
||||
* a rendering is ~190 views rather than ~740.
|
||||
* Exact costs one serialised copy per host size, which is why a rendering is
|
||||
* held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month.
|
||||
*/
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@@ -223,15 +223,13 @@ 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.forEachIndexed { i, week ->
|
||||
layoutWeeks(weeks, source.today).forEach { layout ->
|
||||
WeekRow(
|
||||
week = week,
|
||||
laneCap = caps[i],
|
||||
layout = layout,
|
||||
currentMonth = ym.month,
|
||||
today = source.today,
|
||||
soften = source.soften,
|
||||
@@ -340,8 +338,7 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
week: MonthWeek,
|
||||
laneCap: Int,
|
||||
layout: WeekLayout,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
@@ -349,7 +346,7 @@ private fun WeekRow(
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val overflow = overflowCells(week.hiddenPerDay(laneCap))
|
||||
val week = layout.week
|
||||
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
|
||||
@@ -387,12 +384,12 @@ private fun WeekRow(
|
||||
// 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.
|
||||
// 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)
|
||||
// fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds.
|
||||
layout.lanes.forEach { cells ->
|
||||
LaneRow(cells = cells, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
if (overflow.isNotEmpty()) OverflowRow(cells = overflow, colW = colW)
|
||||
if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,14 +437,15 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(cells: List<LaneCell>, soften: Boolean, colW: Dp) {
|
||||
private fun LaneRow(cells: List<GridCell<EventInstance>>, soften: Boolean, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
cells.forEach { cell ->
|
||||
if (cell.event == null) {
|
||||
val event = cell.item
|
||||
if (event == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
SpanBar(
|
||||
event = cell.event,
|
||||
event = event,
|
||||
soften = soften,
|
||||
width = colW * cell.cols,
|
||||
)
|
||||
@@ -491,14 +489,14 @@ 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(cells: List<OverflowCell>, colW: Dp) {
|
||||
private fun OverflowRow(cells: List<GridCell<Int>>, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
cells.forEach { cell ->
|
||||
if (cell.count == 0) {
|
||||
if (cell.item == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
Text(
|
||||
text = "+${cell.count}",
|
||||
text = "+${cell.item}",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.allDayEvent
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.timedEvent
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -19,60 +22,88 @@ class MonthGridTest {
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
private val today = LocalDate(2026, 7, 15)
|
||||
|
||||
/** Monday Jul 6 to Sunday Jul 12. */
|
||||
private val monday = LocalDate(2026, 7, 6)
|
||||
|
||||
private val android16 = ViewIds(sdkInt = 36)
|
||||
private val android10 = ViewIds(sdkInt = 29)
|
||||
|
||||
private fun weeks(events: List<EventInstance>): List<MonthWeek> =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
|
||||
|
||||
/** July 2026 with [perDay] timed events on every day of the grid. */
|
||||
private fun weeks(perDay: Int): List<de.jeanlucmakiola.calendula.ui.month.MonthWeek> {
|
||||
private fun busyWeeks(perDay: Int): List<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 weeks(
|
||||
(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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<WeekLayout>.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) }
|
||||
|
||||
@Test
|
||||
fun `a quiet month keeps every lane it uses`() {
|
||||
val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16)
|
||||
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1)
|
||||
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full month folds lanes until it fits, on every Android version`() {
|
||||
for (ids in listOf(android16, android10)) {
|
||||
val weeks = busyWeeks(perDay = 5)
|
||||
assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed)
|
||||
|
||||
val layouts = layoutWeeks(weeks, today, ids)
|
||||
assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed)
|
||||
assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue()
|
||||
}
|
||||
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()
|
||||
fun `older Android folds more, since a sized view costs it two ids`() {
|
||||
val weeks = busyWeeks(perDay = 3)
|
||||
val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap }
|
||||
val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap }
|
||||
assertThat(older).isLessThan(newer)
|
||||
}
|
||||
|
||||
@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)
|
||||
val layout = WeekLayout(busyWeeks(perDay = 5)[1], laneCap = 1)
|
||||
assertThat(layout.usedLanes).isEqualTo(1)
|
||||
assertThat(layout.overflow).containsExactly(GridCell(4, 1), GridCell(4, 1), GridCell(4, 1),
|
||||
GridCell(4, 1), GridCell(4, 1), GridCell(4, 1), GridCell(4, 1)).inOrder()
|
||||
}
|
||||
|
||||
@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()
|
||||
fun `a multi-day bar is one cell as wide as its days`() {
|
||||
val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1)
|
||||
val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES)
|
||||
assertThat(layout.lanes).hasSize(1)
|
||||
assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder()
|
||||
}
|
||||
|
||||
@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()
|
||||
fun `a bar on a folded lane moves into the overflow of each day it covers`() {
|
||||
val top = allDayEvent(monday, monday.plus(6, DateTimeUnit.DAY), id = 1)
|
||||
val lower = allDayEvent(monday.plus(2, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 2)
|
||||
val layout = WeekLayout(weeks(listOf(top, lower))[1], laneCap = 1)
|
||||
assertThat(layout.lanes).hasSize(1)
|
||||
assertThat(layout.lanes[0]).containsExactly(GridCell(top, 7))
|
||||
assertThat(layout.overflow).containsExactly(GridCell(null, 2), GridCell(1, 1), GridCell(1, 1), GridCell(null, 3))
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gaps are merged`() {
|
||||
val counts = listOf(0, 0, 2, 0, 0, 0, 1)
|
||||
val cells = mergeGaps(counts.size) { col -> counts[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
|
||||
assertThat(cells).containsExactly(GridCell(null, 2), GridCell(2, 1), GridCell(null, 3), GridCell(1, 1))
|
||||
.inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user