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

@@ -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.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
@@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
@@ -107,6 +108,13 @@ 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
// 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) {
val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and
@@ -136,16 +144,19 @@ private sealed interface AgendaRow {
@Composable
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(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader()
AgendaHeader(metrics)
Spacer(GlanceModifier.height(4.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back
// 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,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
WidgetMessage(R.string.agenda_empty_title, metrics)
} else {
val rows = buildList {
visibleDays.forEach { day ->
@@ -194,8 +205,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
@@ -204,6 +215,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
metrics = metrics,
)
}
}
@@ -215,7 +227,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
}
@Composable
private fun AgendaHeader() {
private fun AgendaHeader(metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
@@ -227,7 +239,7 @@ private fun AgendaHeader() {
text = context.getString(R.string.widget_agenda_title),
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 16.sp,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
@@ -240,6 +252,7 @@ private fun AgendaHeader() {
resId = R.drawable.ic_widget_refresh,
contentDescription = context.getString(R.string.widget_refresh),
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
metrics = metrics,
)
IconButton(
resId = R.drawable.ic_widget_add,
@@ -249,34 +262,40 @@ private fun AgendaHeader() {
MainActivity.openCreateIntent(context, today(systemZone())),
),
),
metrics = metrics,
)
}
}
@Composable
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
private fun IconButton(
resId: Int,
contentDescription: String,
onClick: GlanceModifier,
metrics: AgendaMetrics,
) {
Box(
modifier = GlanceModifier.size(40.dp).then(onClick),
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp),
modifier = GlanceModifier.size(metrics.iconImage),
)
}
}
@Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = agendaDayLabel(context, date, today),
style = TextStyle(
color = if (date == today) GlanceTheme.colors.primary
else GlanceTheme.colors.onSurfaceVariant,
fontSize = 13.sp,
fontSize = metrics.dayHeader,
fontWeight = FontWeight.Medium,
),
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.
*/
@Composable
private fun PlaceholderRow(date: LocalDate) {
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
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
.fillMaxWidth()
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
@@ -320,6 +339,7 @@ private fun EventRow(
soften: Boolean,
is24Hour: Boolean,
dimmed: Boolean,
metrics: AgendaMetrics,
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
@@ -332,7 +352,7 @@ private fun EventRow(
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp)
.padding(horizontal = 4.dp, vertical = metrics.rowVPad)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
@@ -349,7 +369,7 @@ private fun EventRow(
Box(
modifier = GlanceModifier
.width(5.dp)
.height(36.dp)
.height(metrics.stripeH)
.cornerRadius(3.dp)
.background(stripeColor),
) {}
@@ -358,19 +378,19 @@ private fun EventRow(
Text(
text = title,
maxLines = 1,
style = TextStyle(color = titleColor, fontSize = 14.sp),
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
)
}
}
}
@Composable
private fun WidgetMessage(resId: Int) {
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Box(
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
@@ -378,7 +398,7 @@ private fun WidgetMessage(resId: Int) {
) {
Text(
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)
}
}
}