diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e901b4..d807ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Added @@ -1038,5 +1046,6 @@ automatically, with zero telemetry and no internet permission. [#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 +[#51]: https://codeberg.org/jlmakiola/calendula/issues/51 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt new file mode 100644 index 0000000..5e1880d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 198a21a..c2fe4de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -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()), + 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), ) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt new file mode 100644 index 0000000..6ce534e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -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) + } + } +}