diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ce563..d10b846 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 @@ -1047,6 +1055,7 @@ 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 [#65]: https://codeberg.org/jlmakiola/calendula/issues/65 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt new file mode 100644 index 0000000..9b19179 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt @@ -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) +} 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..1e55303 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -0,0 +1,150 @@ +package de.jeanlucmakiola.calendula.widget.agenda + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.jeanlucmakiola.calendula.widget.WidgetScale + +/** + * 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 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 + +/** + * 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 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, +) + +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, +) + +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] 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..7a7f7d9 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 @@ -61,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 @@ -107,6 +109,19 @@ class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition + // 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). 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) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -126,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 @@ -136,16 +160,22 @@ 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). + // 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() .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 +209,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 -> @@ -191,11 +221,15 @@ 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]) { - 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 +238,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { is24Hour = data.is24Hour, dimmed = pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), + metrics = metrics, ) } } @@ -215,7 +250,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 +262,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 +275,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,39 +285,50 @@ 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 .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), @@ -296,14 +343,19 @@ 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) + .padding( + start = TEXT_INDENT, + end = 8.dp, + top = 2.dp, + bottom = metrics.rowVPad + 2.dp, + ) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), @@ -320,6 +372,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 +385,7 @@ private fun EventRow( Row( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp) + .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -348,29 +401,29 @@ private fun EventRow( ) { Box( modifier = GlanceModifier - .width(5.dp) - .height(36.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, 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 +431,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/WidgetScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt new file mode 100644 index 0000000..287400c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt @@ -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))) + } + } + } +} 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..edc84c4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -0,0 +1,121 @@ +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 de.jeanlucmakiola.calendula.widget.WidgetScale +import de.jeanlucmakiola.calendula.widget.scaleFor +import org.junit.jupiter.api.Test + +class AgendaScaleTest { + + // --- the "default size is unchanged" regression guard --------------------- + + @Test + 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 `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(WidgetScale.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) + assertThat(m.dayHeaderTopPad).isEqualTo(10.dp) + } + + @Test + 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) + 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) + 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) + } +}