fix(widget): address review of the agenda size scaling (#51)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m52s

Follow-up to 30fcbfa, fixing eight issues found in review:

- Cap the agenda row list at 100. SizeMode.Exact asks Glance for one
  RemoteViews per host size where SizeMode.Single produced exactly one,
  roughly doubling the payload; with the range reaching
  AgendaRange.MAX_CUSTOM_DAYS (365) an uncapped list could push past the
  binder transaction limit and the host would show "Problem loading
  widget". A trailing day header stranded by the cut is dropped.

- Loosen the height cap so it only catches genuinely squashed widgets.
  It previously required ~320dp of height for LARGE, so widening a widget
  without also making it unusually tall — the exact resize #51 reports —
  stayed REGULAR or COMPACT and the feature was near a no-op for it.
  Thresholds now work on height minus header chrome.

- Lock the event stripe to the system font scale. It is a Dp beside sp
  text, so at large accessibility settings the text outgrew it and it
  under-ran the row it marks.

- Route the day-header and placeholder padding through the metrics table
  so vertical rhythm holds at the larger tiers, and derive the text
  indent from the row constants instead of a hardcoded 19dp.

- Share the bucketing as widget/WidgetScale.kt so MonthWidget (already
  SizeMode.Exact) can adopt one rule rather than growing a parallel copy.

- Anchor the type ramp to Material 3 type-scale roles per CLAUDE.md, with
  the two off-scale values marked and justified inline. COMPACT is
  unchanged, so a default-sized widget still looks exactly as before.

- Tie the "default size unchanged" test to the provider XML's declared
  3-cell band rather than one measured 222dp point.

- Hang the metrics off an ordinal-indexed table so lookup allocates
  nothing per recomposition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:47:37 +02:00
parent 30fcbfa59f
commit a34f29bcf8
5 changed files with 412 additions and 183 deletions

View File

@@ -0,0 +1,70 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
/**
* Size tiers a widget scales its typography and metrics across (#51).
*
* Shared by every Glance widget so the bucketing rule can't drift between them:
* each widget keeps its own metrics table, but they all agree on *when* a widget
* counts as compact, regular, large or extra-large. Both widgets already declare
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
* size via `LocalSize.current` and passes it to [scaleFor].
*
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
* covered by plain JVM tests.
*/
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
/**
* Chrome a widget spends before its first content row: outer vertical padding
* plus a header row and its spacer. Subtracted from the raw height so the height
* thresholds below talk about *usable* space rather than gross widget height.
*/
private val CHROME_HEIGHT = 60.dp
/**
* Buckets a live widget size into a [WidgetScale] by **width**.
*
* Width is the right axis: it governs how much of a title fits on a row, so it's
* what should drive type size. Height only decides how many rows are visible — a
* tall, narrow widget wants *more events*, not bigger text — so it never raises
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
* stepped back down so it can't keep oversized type in a sliver of space.
*
* The width thresholds are spread across the range a phone can actually produce
* (~180dp up to roughly the screen width) rather than over a theoretical range,
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
* surfaces — tablets, foldables, landscape — where the extra size reads well.
*
* The height cap is deliberately generous: it exists to catch a widget squashed
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
* the usual three cells tall (~270dp) must still reach the tier its width earned
* — that is exactly the resize #51 reports, and an aggressive cap would make the
* whole feature a no-op for it.
*/
internal fun scaleFor(size: DpSize): WidgetScale {
val byWidth = when {
size.width < 260.dp -> WidgetScale.COMPACT
size.width < 330.dp -> WidgetScale.REGULAR
size.width < 420.dp -> WidgetScale.LARGE
else -> WidgetScale.XLARGE
}
// Height can only ever pull the tier *down*, never push it up: a squashed
// widget would otherwise keep the big type its width earned and look absurd
// in the little space left. Keeping this a cap (rather than a second scaling
// axis) is what preserves "tall and narrow shows more events, not bigger
// text". Thresholds are usable height — roughly one, two and three rows of
// breathing room once the header is paid for.
val usable = size.height - CHROME_HEIGHT
val heightCap = when {
usable < 70.dp -> WidgetScale.COMPACT
usable < 130.dp -> WidgetScale.REGULAR
usable < 200.dp -> WidgetScale.LARGE
else -> WidgetScale.XLARGE
}
return minOf(byWidth, heightCap)
}

View File

@@ -1,129 +1,150 @@
package de.jeanlucmakiola.calendula.widget.agenda
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetScale
/**
* Size tiers the agenda widget scales its typography and row metrics across (#51).
*
* The widget uses [androidx.glance.appwidget.SizeMode.Exact], so the composition
* sees the widget's live size via `LocalSize.current`; [scaleFor] buckets that
* into one of these tiers and [metricsFor] returns the sizes to draw with. Kept
* in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing and the
* "default size is unchanged" invariant are covered by plain JVM tests.
* Horizontal layout constants for an agenda event row. These don't scale with the
* tier — a wider stripe or gap would eat title width, which is the thing the row
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
* the "nothing left today" line can never drift out of alignment with the event
* title column the way a hardcoded 19dp could.
*/
internal enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE }
internal val ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
internal val STRIPE_GAP = 10.dp
/** Left indent that lines non-event text up with the event title column. */
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
/**
* Every size the agenda widget varies by tier. Values that don't need to grow
* (horizontal paddings, the 5.dp stripe width, corner radii) stay inline literals
* in the widget rather than routing through here.
* The width band a *default* agenda placement can land in, per
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
* launcher cell grids vary, so the whole band — not one measured point — has to
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
* to hold. A test pins that.
*
* If the provider's `targetCellWidth` ever changes, this band and the first
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
* revisited together.
*/
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
/**
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
* grow (the horizontal row constants above, corner radii) stay constants rather
* than routing through here.
*/
internal data class AgendaMetrics(
val title: TextUnit, // header "Upcoming"
val dayHeader: TextUnit, // day label ("Today · …")
val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message
val stripeH: Dp, // coloured event stripe height
val iconImage: Dp, // header action icon glyph
val iconBox: Dp, // header action touch target
val rowVPad: Dp, // event row vertical padding
val title: TextUnit, // header "Upcoming"
val dayHeader: TextUnit, // day label ("Today · …")
val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message
val stripeH: Dp, // coloured event stripe height
val iconImage: Dp, // header action icon glyph
val iconBox: Dp, // header action touch target
val rowVPad: Dp, // event row vertical padding
val dayHeaderTopPad: Dp, // space above a day header
) {
/**
* Resolves the stripe height against the user's system font scale.
*
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
* they scale with the accessibility font setting and the stripe does not —
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
* it is supposed to mark. Multiplying by the same factor keeps them locked,
* and at the default scale of 1.0 reproduces the tier's value exactly.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
}
/*
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
*
* Two documented deviations:
*
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
* COMPACT reproduces the widget's original constants verbatim so a
* default-sized widget looks exactly as it did (#51), and snapping it to
* Title Small (14sp) would break that promise for a 1sp gain.
*
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
* which is far too coarse for four widget tiers. Where a role would force a
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
* mark it. The endpoints stay on real roles.
*/
private val COMPACT_METRICS = AgendaMetrics(
title = 16.sp, // M3 Title Medium
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
eventTitle = 14.sp, // M3 Body Medium
eventTime = 12.sp, // M3 Body Small
placeholder = 14.sp, // M3 Body Medium
message = 14.sp, // M3 Body Medium
stripeH = 36.dp,
iconImage = 22.dp,
iconBox = 40.dp,
rowVPad = 4.dp,
dayHeaderTopPad = 10.dp,
)
/**
* [AgendaMetrics] per tier. [AgendaScale.COMPACT] reproduces the widget's original
* hardcoded constants **verbatim** — so at the default/small size nothing changes;
* a JVM test pins this against the baseline. Larger tiers scale up by roughly
* 1.12× / 1.28× / 1.45× (starting points to refine on-device).
*/
internal fun metricsFor(scale: AgendaScale): AgendaMetrics = when (scale) {
AgendaScale.COMPACT -> AgendaMetrics(
title = 16.sp,
dayHeader = 13.sp,
eventTitle = 14.sp,
eventTime = 12.sp,
placeholder = 14.sp,
message = 14.sp,
stripeH = 36.dp,
iconImage = 22.dp,
iconBox = 40.dp,
rowVPad = 4.dp,
)
AgendaScale.REGULAR -> AgendaMetrics(
title = 18.sp,
dayHeader = 14.sp,
eventTitle = 15.sp,
eventTime = 13.sp,
placeholder = 15.sp,
message = 15.sp,
stripeH = 40.dp,
iconImage = 24.dp,
iconBox = 44.dp,
rowVPad = 5.dp,
)
AgendaScale.LARGE -> AgendaMetrics(
title = 20.sp,
dayHeader = 16.sp,
eventTitle = 17.sp,
eventTime = 14.sp,
placeholder = 16.sp,
message = 16.sp,
stripeH = 46.dp,
iconImage = 26.dp,
iconBox = 48.dp,
rowVPad = 6.dp,
)
AgendaScale.XLARGE -> AgendaMetrics(
title = 22.sp,
dayHeader = 18.sp,
eventTitle = 19.sp,
eventTime = 15.sp,
placeholder = 18.sp,
message = 18.sp,
stripeH = 52.dp,
iconImage = 28.dp,
iconBox = 52.dp,
rowVPad = 8.dp,
)
}
private val REGULAR_METRICS = AgendaMetrics(
title = 18.sp, // †
dayHeader = 14.sp, // M3 Title Small
eventTitle = 16.sp, // M3 Body Large
eventTime = 14.sp, // M3 Body Medium
placeholder = 16.sp, // M3 Body Large
message = 16.sp, // M3 Body Large
stripeH = 40.dp,
iconImage = 24.dp,
iconBox = 44.dp,
rowVPad = 5.dp,
dayHeaderTopPad = 11.dp,
)
/**
* Buckets a live widget size into an [AgendaScale] by **width**.
*
* Width is the right axis: it governs how much of a title fits on a row, so it's
* what should drive type size. Height only decides how many rows are visible — a
* tall, narrow widget wants *more events*, not bigger text — so it never raises
* the tier. It does act as a **cap**, though: a very short widget is stepped back
* down so it can't keep oversized type in a sliver of space.
*
* The thresholds are spread across the width range a phone can actually produce
* (~180dp up to roughly the screen width) rather than over a theoretical range, so
* the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
* large 378dp-wide one reaches LARGE. XLARGE is reserved for genuinely wide
* surfaces — tablets, foldables, landscape — where the extra size reads well.
*/
internal fun scaleFor(size: DpSize): AgendaScale {
val byWidth = when {
size.width < 260.dp -> AgendaScale.COMPACT
size.width < 330.dp -> AgendaScale.REGULAR
size.width < 420.dp -> AgendaScale.LARGE
else -> AgendaScale.XLARGE
}
// Height can only ever pull the tier *down*, never push it up: a squashed
// widget would otherwise keep the big type its width earned and look absurd
// in the little space left. Keeping this a cap (rather than a second scaling
// axis) is what preserves "tall and narrow shows more events, not bigger text".
val heightCap = when {
size.height < 220.dp -> AgendaScale.COMPACT
size.height < 320.dp -> AgendaScale.REGULAR
size.height < 420.dp -> AgendaScale.LARGE
else -> AgendaScale.XLARGE
}
return minOf(byWidth, heightCap)
}
private val LARGE_METRICS = AgendaMetrics(
title = 20.sp, // †
dayHeader = 16.sp, // M3 Title Medium
eventTitle = 18.sp, // †
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
placeholder = 18.sp, // † on purpose, so the title keeps its
message = 18.sp, // † lead and the hierarchy survives.
stripeH = 46.dp,
iconImage = 26.dp,
iconBox = 48.dp,
rowVPad = 6.dp,
dayHeaderTopPad = 12.dp,
)
private val XLARGE_METRICS = AgendaMetrics(
title = 22.sp, // M3 Title Large
dayHeader = 18.sp, // †
eventTitle = 20.sp, // †
eventTime = 16.sp, // M3 Body Large
placeholder = 20.sp, // †
message = 20.sp, // †
stripeH = 52.dp,
iconImage = 28.dp,
iconBox = 52.dp,
rowVPad = 8.dp,
dayHeaderTopPad = 14.dp,
)
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
private val AGENDA_METRICS = listOf(
COMPACT_METRICS,
REGULAR_METRICS,
LARGE_METRICS,
XLARGE_METRICS,
)
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]

View File

@@ -62,6 +62,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.scaleFor
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
@@ -108,11 +109,17 @@ class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact (not Responsive) so a single copy of the day/event list is laid out at
// the widget's live size — Responsive would replicate the whole LazyColumn per
// declared tier. The composition reads LocalSize.current and scales type/rows
// Exact so the composition sees the widget's live size and can scale type/rows
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
// COMPACT, i.e. the layout is unchanged (#51).
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
// same.
//
// Note Exact still asks Glance for one RemoteViews per host size (typically
// portrait + landscape) where the old SizeMode.Single produced exactly one —
// so the serialized payload roughly doubles. That is why the row list below is
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
// = 365) could otherwise push the RemoteViews past the binder transaction
// limit and the host would just show "Problem loading widget".
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -134,6 +141,15 @@ class RefreshAgendaAction : ActionCallback {
}
}
/**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
* user scrolling a home-screen widget past a hundred rows is not a case worth
* risking a failed update for.
*/
private const val MAX_AGENDA_ROWS = 100
/** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
@@ -146,7 +162,10 @@ private sealed interface AgendaRow {
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
val metrics = metricsFor(scaleFor(LocalSize.current))
// The stripe is then re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
Column(
modifier = GlanceModifier
.fillMaxSize()
@@ -202,6 +221,10 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
}
}
}
// Bound the payload, then drop a day header the cut left
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
@@ -300,7 +323,12 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetri
),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.padding(
start = 8.dp,
end = 8.dp,
top = metrics.dayHeaderTopPad,
bottom = metrics.rowVPad,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
@@ -322,7 +350,12 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
.padding(
start = TEXT_INDENT,
end = 8.dp,
top = 2.dp,
bottom = metrics.rowVPad + 2.dp,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
@@ -352,7 +385,7 @@ private fun EventRow(
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = metrics.rowVPad)
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
@@ -368,12 +401,12 @@ private fun EventRow(
) {
Box(
modifier = GlanceModifier
.width(5.dp)
.width(STRIPE_WIDTH)
.height(metrics.stripeH)
.cornerRadius(3.dp)
.background(stripeColor),
) {}
Spacer(GlanceModifier.width(10.dp))
Spacer(GlanceModifier.width(STRIPE_GAP))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,

View File

@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetScaleTest {
@Test
fun `the on-device calibration points map to their tiers`() {
// The two sizes measured on-device: the compact widget stays COMPACT (the
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
// which read as too big on a phone (#51).
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
}
@Test
fun `a full-width widget at ordinary height still scales up`() {
// The regression the height cap used to cause: widening the widget without
// also making it unusually tall is *the* resize #51 reports, and it must
// reach the tier its width earned. Three cells tall is about 270dp.
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `width buckets into the four tiers`() {
// Tall enough that the height cap never binds, isolating the width rule.
val h = 500.dp
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `extra height never raises the tier`() {
// Height decides how many rows are visible, not how big they are: a tall,
// narrow widget wants more events, not bigger text.
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
}
@Test
fun `only a genuinely squashed widget is stepped back down`() {
// Same (wide) width, shrinking height. The cap exists to stop oversized type
// surviving in a one- or two-row sliver — it must not fire at normal heights.
// Heights are gross; the cap works on height minus 60dp of chrome, so the
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
val wide = 378.dp
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
}
@Test
fun `the tier is monotonic in both axes`() {
// Growing a widget must never make its type smaller. Sweeps the whole
// plausible range rather than spot-checking, so a future threshold edit
// can't accidentally invert a step.
val widths = (110..900 step 7).map { it.dp }
val heights = (110..900 step 7).map { it.dp }
widths.forEach { w ->
heights.zipWithNext { shorter, taller ->
assertThat(scaleFor(DpSize(w, taller)))
.isAtLeast(scaleFor(DpSize(w, shorter)))
}
}
heights.forEach { h ->
widths.zipWithNext { narrower, wider ->
assertThat(scaleFor(DpSize(wider, h)))
.isAtLeast(scaleFor(DpSize(narrower, h)))
}
}
}
}

View File

@@ -4,70 +4,32 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.widget.WidgetScale
import de.jeanlucmakiola.calendula.widget.scaleFor
import org.junit.jupiter.api.Test
class AgendaScaleTest {
// --- scaleFor: bucketing -------------------------------------------------
// --- the "default size is unchanged" regression guard ---------------------
@Test
fun `the on-device calibration points map to their tiers`() {
// The two sizes measured on-device: the compact widget stays COMPACT (the
// baseline, unchanged), the large one steps up to LARGE — not XLARGE,
// which read as too big on a phone (#51).
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(AgendaScale.COMPACT)
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(AgendaScale.LARGE)
fun `every default placement width stays COMPACT`() {
// Ties the guarantee to the provider XML's declared 3-cell default rather
// than to one measured launcher: the whole band a default placement can
// land in must bucket to COMPACT, or a freshly placed widget silently
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
val band = AGENDA_DEFAULT_WIDTH_BAND
var w = band.start
while (w <= band.endInclusive) {
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
w += 1.dp
}
}
@Test
fun `width buckets into the four tiers`() {
// Tall enough that the height cap never binds, isolating the width rule.
val h = 500.dp
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(AgendaScale.COMPACT)
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(AgendaScale.COMPACT)
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(AgendaScale.REGULAR)
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(AgendaScale.REGULAR)
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(AgendaScale.LARGE)
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(AgendaScale.LARGE)
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(AgendaScale.XLARGE)
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(AgendaScale.XLARGE)
}
@Test
fun `extra height never raises the tier`() {
// Height decides how many rows are visible, not how big they are: a tall,
// narrow widget wants more events, not bigger text.
val short = scaleFor(DpSize(222.dp, 200.dp))
val tall = scaleFor(DpSize(222.dp, 900.dp))
assertThat(short).isEqualTo(AgendaScale.COMPACT)
assertThat(tall).isEqualTo(AgendaScale.COMPACT)
}
@Test
fun `a squashed widget is stepped back down`() {
// Same (wide) width, shrinking height: the tier must walk down rather than
// keep big type in a sliver of space.
val wide = 378.dp
assertThat(scaleFor(DpSize(wide, 672.dp))).isEqualTo(AgendaScale.LARGE)
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(AgendaScale.LARGE)
assertThat(scaleFor(DpSize(wide, 300.dp))).isEqualTo(AgendaScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 200.dp))).isEqualTo(AgendaScale.COMPACT)
}
@Test
fun `the height cap never exceeds what the width earned`() {
// A tall but narrow widget stays at its width's tier — the cap only ever
// lowers, so a huge height can't promote a 222dp-wide widget.
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(AgendaScale.COMPACT)
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(AgendaScale.REGULAR)
}
// --- metricsFor: the "default unchanged" regression guard -----------------
@Test
fun `COMPACT metrics equal the widget's original constants`() {
// If this fails, a default-sized agenda widget no longer looks as it did.
val m = metricsFor(AgendaScale.COMPACT)
val m = metricsFor(WidgetScale.COMPACT)
assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
@@ -78,16 +40,22 @@ class AgendaScaleTest {
assertThat(m.iconImage).isEqualTo(22.dp)
assertThat(m.iconBox).isEqualTo(40.dp)
assertThat(m.rowVPad).isEqualTo(4.dp)
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
}
@Test
fun `type sizes are non-decreasing across the tiers`() {
val tiers = listOf(
AgendaScale.COMPACT,
AgendaScale.REGULAR,
AgendaScale.LARGE,
AgendaScale.XLARGE,
).map(::metricsFor)
fun `the derived text indent matches the original hardcoded inset`() {
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
// day headers and the placeholder line stop aligning with event titles.
assertThat(TEXT_INDENT).isEqualTo(19.dp)
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
}
// --- the ramp ------------------------------------------------------------
@Test
fun `sizes are non-decreasing across the tiers`() {
val tiers = WidgetScale.entries.map(::metricsFor)
tiers.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value)
@@ -100,6 +68,54 @@ class AgendaScaleTest {
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
}
}
@Test
fun `the event title keeps its lead over the time line`() {
// The secondary line steps more slowly on purpose; if it ever caught up the
// row would lose its hierarchy.
WidgetScale.entries.map(::metricsFor).forEach { m ->
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
}
}
@Test
fun `no tier grows type more than half again over the baseline`() {
// Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetScale.COMPACT)
val top = metricsFor(WidgetScale.XLARGE)
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
}
// --- font scale ----------------------------------------------------------
@Test
fun `the stripe tracks the system font scale`() {
// The stripe is Dp, the text beside it is sp: without this the two diverge
// at large accessibility font settings and the stripe under-runs the row.
val m = metricsFor(WidgetScale.COMPACT)
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
}
@Test
fun `scaling for the default font scale changes nothing`() {
val m = metricsFor(WidgetScale.LARGE)
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
}
@Test
fun `font scaling leaves the sp sizes alone`() {
// Glance already applies the font scale to sp; scaling them here too would
// double-count it.
val m = metricsFor(WidgetScale.REGULAR)
val scaled = m.scaledForFont(1.3f)
assertThat(scaled.title).isEqualTo(m.title)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
}
}