fix(widget): scale the agenda widget with its size (#51)

The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was
composed once at the minimum size and the launcher stretched that single
RemoteViews when enlarged, so the text stayed small-widget-sized no matter how
big the widget grew. Reported as a "font size" request (#51), but it's really a
missing size-response.

Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick
one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics.
Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier.

Width picks the tier, height can only lower it. Width 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, so a tall narrow widget shows more events rather than
bigger text. Height does act as a cap, though, or a squashed widget would keep
the large type its width earned in a sliver of space. Thresholds are spread over
the width range a phone actually produces (measured on a Pixel/Nova: a compact
widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the
tiers are reachable in practice; XLARGE is reserved for tablets/foldables.

COMPACT reproduces the original constants verbatim, so an existing widget is
visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt
(compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width
buckets, the height cap stepping a squashed widget down, and that height never
raises the tier.

No new setting: the widget follows the size the launcher/user already chose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:22:07 +02:00
parent 9a1d753abc
commit 30fcbfa59f
4 changed files with 284 additions and 21 deletions

View File

@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- The "Upcoming" agenda widget now scales its text and rows to the size you give
it. Previously it was laid out once for the smallest size and simply stretched
when enlarged, so the text stayed small no matter how big you made the widget.
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
## [2.16.0] — 2026-07-17 ## [2.16.0] — 2026-07-17
### Added ### Added
@@ -1038,5 +1046,6 @@ automatically, with zero telemetry and no internet permission.
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#47]: https://codeberg.org/jlmakiola/calendula/issues/47
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60

View File

@@ -0,0 +1,129 @@
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
/**
* 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.
*/
internal enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE }
/**
* 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.
*/
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
)
/**
* [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,
)
}
/**
* 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)
}

View File

@@ -5,7 +5,6 @@ import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
@@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
@@ -107,6 +108,13 @@ class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition 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
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
// COMPACT, i.e. the layout is unchanged (#51).
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData() val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
@@ -136,16 +144,19 @@ private sealed interface AgendaRow {
@Composable @Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { 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))
Column( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.background(GlanceTheme.colors.surface) .background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
) { ) {
AgendaHeader() AgendaHeader(metrics)
Spacer(GlanceModifier.height(4.dp)) Spacer(GlanceModifier.height(4.dp))
when (data) { when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> { is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back // Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide // to the saved pref for a freshly placed widget), then the wide
@@ -179,7 +190,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
enabled = showToday, enabled = showToday,
) )
if (visibleDays.isEmpty()) { if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title) WidgetMessage(R.string.agenda_empty_title, metrics)
} else { } else {
val rows = buildList { val rows = buildList {
visibleDays.forEach { day -> visibleDays.forEach { day ->
@@ -194,8 +205,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index -> items(rows.size) { index ->
when (val row = rows[index]) { when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today) is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
is AgendaRow.Placeholder -> PlaceholderRow(row.date) is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow( is AgendaRow.Event -> EventRow(
event = row.event, event = row.event,
day = row.date, day = row.date,
@@ -204,6 +215,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
is24Hour = data.is24Hour, is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM && dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now), row.event.hasEnded(data.now),
metrics = metrics,
) )
} }
} }
@@ -215,7 +227,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
} }
@Composable @Composable
private fun AgendaHeader() { private fun AgendaHeader(metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Row( Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
@@ -227,7 +239,7 @@ private fun AgendaHeader() {
text = context.getString(R.string.widget_agenda_title), text = context.getString(R.string.widget_agenda_title),
style = TextStyle( style = TextStyle(
color = GlanceTheme.colors.primary, color = GlanceTheme.colors.primary,
fontSize = 16.sp, fontSize = metrics.title,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier modifier = GlanceModifier
@@ -240,6 +252,7 @@ private fun AgendaHeader() {
resId = R.drawable.ic_widget_refresh, resId = R.drawable.ic_widget_refresh,
contentDescription = context.getString(R.string.widget_refresh), contentDescription = context.getString(R.string.widget_refresh),
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()), onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
metrics = metrics,
) )
IconButton( IconButton(
resId = R.drawable.ic_widget_add, resId = R.drawable.ic_widget_add,
@@ -249,34 +262,40 @@ private fun AgendaHeader() {
MainActivity.openCreateIntent(context, today(systemZone())), MainActivity.openCreateIntent(context, today(systemZone())),
), ),
), ),
metrics = metrics,
) )
} }
} }
@Composable @Composable
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) { private fun IconButton(
resId: Int,
contentDescription: String,
onClick: GlanceModifier,
metrics: AgendaMetrics,
) {
Box( Box(
modifier = GlanceModifier.size(40.dp).then(onClick), modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Image(
provider = ImageProvider(resId), provider = ImageProvider(resId),
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp), modifier = GlanceModifier.size(metrics.iconImage),
) )
} }
} }
@Composable @Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate) { private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Text( Text(
text = agendaDayLabel(context, date, today), text = agendaDayLabel(context, date, today),
style = TextStyle( style = TextStyle(
color = if (date == today) GlanceTheme.colors.primary color = if (date == today) GlanceTheme.colors.primary
else GlanceTheme.colors.onSurfaceVariant, else GlanceTheme.colors.onSurfaceVariant,
fontSize = 13.sp, fontSize = metrics.dayHeader,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier modifier = GlanceModifier
@@ -296,11 +315,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
* plain too (a stripe + text, not cards), so a card here would look out of place. * plain too (a stripe + text, not cards), so a card here would look out of place.
*/ */
@Composable @Composable
private fun PlaceholderRow(date: LocalDate) { private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Text( Text(
text = context.getString(R.string.agenda_no_more_today), text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp) .padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
@@ -320,6 +339,7 @@ private fun EventRow(
soften: Boolean, soften: Boolean,
is24Hour: Boolean, is24Hour: Boolean,
dimmed: Boolean, dimmed: Boolean,
metrics: AgendaMetrics,
) { ) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
@@ -332,7 +352,7 @@ private fun EventRow(
Row( Row(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = 4.dp, vertical = metrics.rowVPad)
.clickable( .clickable(
actionStartActivity( actionStartActivity(
MainActivity.openEventIntent( MainActivity.openEventIntent(
@@ -349,7 +369,7 @@ private fun EventRow(
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(5.dp) .width(5.dp)
.height(36.dp) .height(metrics.stripeH)
.cornerRadius(3.dp) .cornerRadius(3.dp)
.background(stripeColor), .background(stripeColor),
) {} ) {}
@@ -358,19 +378,19 @@ private fun EventRow(
Text( Text(
text = title, text = title,
maxLines = 1, maxLines = 1,
style = TextStyle(color = titleColor, fontSize = 14.sp), style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
) )
Text( Text(
text = eventTimeSummary(context, event, day, is24Hour), text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
) )
} }
} }
} }
@Composable @Composable
private fun WidgetMessage(resId: Int) { private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Box( Box(
modifier = GlanceModifier.fillMaxSize().padding(16.dp), modifier = GlanceModifier.fillMaxSize().padding(16.dp),
@@ -378,7 +398,7 @@ private fun WidgetMessage(resId: Int) {
) { ) {
Text( Text(
text = context.getString(resId), text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
) )
} }
} }

View File

@@ -0,0 +1,105 @@
package de.jeanlucmakiola.calendula.widget.agenda
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 org.junit.jupiter.api.Test
class AgendaScaleTest {
// --- scaleFor: bucketing -------------------------------------------------
@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)
}
@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)
assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
assertThat(m.eventTime).isEqualTo(12.sp)
assertThat(m.placeholder).isEqualTo(14.sp)
assertThat(m.message).isEqualTo(14.sp)
assertThat(m.stripeH).isEqualTo(36.dp)
assertThat(m.iconImage).isEqualTo(22.dp)
assertThat(m.iconBox).isEqualTo(40.dp)
assertThat(m.rowVPad).isEqualTo(4.dp)
}
@Test
fun `type sizes are non-decreasing across the tiers`() {
val tiers = listOf(
AgendaScale.COMPACT,
AgendaScale.REGULAR,
AgendaScale.LARGE,
AgendaScale.XLARGE,
).map(::metricsFor)
tiers.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value)
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
assertThat(big.message.value).isAtLeast(small.message.value)
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
}
}
}