From 02cf32b5374269877ab5650e5ed68118d2a84f72 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 19:22:35 +0200 Subject: [PATCH] Draw widgets at a size you pick, not a measured one (#103, #51) (#104) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/104 --- CHANGELOG.md | 17 ++-- .../calendula/data/prefs/SettingsPrefs.kt | 19 ++++ .../calendula/ui/settings/SettingsScreen.kt | 31 +++++++ .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 31 ++++++- .../calendula/widget/WidgetData.kt | 3 + .../calendula/widget/WidgetScale.kt | 70 --------------- .../calendula/widget/WidgetSize.kt | 20 +++++ .../calendula/widget/agenda/AgendaScale.kt | 56 +++++------- .../calendula/widget/agenda/AgendaWidget.kt | 44 +++++---- app/src/main/res/values/strings.xml | 7 ++ .../calendula/widget/WidgetScaleTest.kt | 89 ------------------- .../widget/agenda/AgendaScaleTest.kt | 45 ++++------ 13 files changed, 187 insertions(+), 248 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetSize.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ba594ec..0ad2d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The calendar picker in the event form and in the .ics import screen now ends with a **"Missing a calendar?"** row that opens Settings → Calendars, where those marks then explain why a calendar isn't offered ([#76]). +- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda + widget size** offers Small, Medium, Large and Extra large, replacing the guess + the widget used to make from its own measurements. Small is what it looks like + today, so nothing changes until you turn it up ([#51]). ### Changed - Calendula's source code now lives on **Codeberg**, where its issues already @@ -37,11 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unaffected. ### Fixed -- A month-grid widget stays a month grid. Since 2.16.0 a placed month widget - could redraw itself as the agenda widget a little after any change to your - events, because the release build merged the two widgets into a single class - and Android could no longer tell which of them a widget on your home screen - was ([#89]). +- A month-grid widget stays a month grid, and draws all seven days again. Since + 2.16.0 a placed month widget could redraw itself as the agenda widget a little + after any change to your events, and could draw only about four day columns + with the last one cut off part-way through. Both came from the release build + merging the two widgets into a single class, so Android could no longer tell + which of them a widget on your home screen was — and the month grid was handed + the wrong widget's measurements to lay its columns out against ([#89], [#103]). - The back gesture on **Settings → Views** returns to Settings instead of leaving Settings altogether and dropping you on the calendar. Special dates did the same ([#81]). @@ -1216,3 +1222,4 @@ automatically, with zero telemetry and no internet permission. [#82]: https://codeberg.org/jlmakiola/calendula/issues/82 [#87]: https://codeberg.org/jlmakiola/calendula/issues/87 [#89]: https://codeberg.org/jlmakiola/calendula/issues/89 +[#103]: https://codeberg.org/jlmakiola/calendula/issues/103 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 5020eb5..52a2518 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -23,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import de.jeanlucmakiola.calendula.widget.WidgetSize import java.time.ZoneId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -306,6 +307,23 @@ class SettingsPrefs @Inject constructor( store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } } + /** + * The size step the agenda widget draws its text at (#51). Defaults to + * [WidgetSize.SMALL], which reproduces its original metrics, so an existing + * widget is unchanged until its owner turns the size up. + * + * This replaced deriving a size tier from the widget's measured size, which + * the launcher does not report reliably. The month widget takes no size + * setting — it divides the width it is given by seven (#103). + */ + val widgetSize: Flow = store.data.map { prefs -> + prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL) + } + + suspend fun setWidgetSize(size: WidgetSize) { + store.edit { it[WIDGET_SIZE_KEY] = size.name } + } + /** * Whether the agenda shows its top range bar — the "showing …" header and * the session range switcher (v2.11). Default ON. @@ -781,6 +799,7 @@ class SettingsPrefs @Inject constructor( internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") + internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size") internal val AGENDA_SHOW_TODAY_KEY = booleanPreferencesKey("agenda_show_today") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 699c08b..2ef5434 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -117,6 +117,7 @@ import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.CalendarView @@ -150,6 +151,7 @@ import de.jeanlucmakiola.calendula.ui.theme.BundledFont import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily +import de.jeanlucmakiola.calendula.widget.WidgetSize import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime import java.time.format.TextStyle as JavaTextStyle @@ -499,6 +501,7 @@ private fun AppearanceScreen( var showDefaultView by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) } + var showWidgetSize by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) } var showPlainFont by remember { mutableStateOf(false) } @@ -684,6 +687,12 @@ private fun AppearanceScreen( }, onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) }, ) + GroupedRow( + title = stringResource(R.string.settings_widget_size), + summary = widgetSizeLabel(state.widgetSize), + position = Position.Middle, + onClick = { showWidgetSize = true }, + ) GroupedRow( title = stringResource(R.string.settings_agenda_range_bar), summary = stringResource(R.string.settings_agenda_range_bar_hint), @@ -810,6 +819,18 @@ private fun AppearanceScreen( onDismiss = { showAgendaWidgetRange = false }, ) } + if (showWidgetSize) { + OptionPicker( + title = stringResource(R.string.settings_widget_size), + header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) }, + predictiveBack = true, + options = WidgetSize.entries, + selected = state.widgetSize, + label = { widgetSizeLabel(it) }, + onSelect = viewModel::setWidgetSize, + onDismiss = { showWidgetSize = false }, + ) + } if (showTimeFormat) { OptionPicker( title = stringResource(R.string.settings_time_format), @@ -2072,6 +2093,16 @@ private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResour }, ) +@Composable +private fun widgetSizeLabel(size: WidgetSize): String = stringResource( + when (size) { + WidgetSize.SMALL -> R.string.settings_widget_size_small + WidgetSize.MEDIUM -> R.string.settings_widget_size_medium + WidgetSize.LARGE -> R.string.settings_widget_size_large + WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large + }, +) + @Composable private fun languageLabel(tag: String?): String = if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 8f59b2c..5c5c19e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.calendula.widget.WidgetSize /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -48,6 +49,8 @@ data class SettingsUiState( val agendaShowToday: Boolean = true, /** Whether the agenda shows its top range bar — header + switcher (v2.11). */ val agendaShowRangeBar: Boolean = true, + /** The size step the agenda widget draws its text at (#51). */ + val widgetSize: WidgetSize = WidgetSize.SMALL, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 8388991..fddee1d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -39,9 +39,11 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN +import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY +import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.CoroutineDispatcher @@ -162,8 +164,9 @@ class SettingsViewModel @Inject constructor( prefs.quickSwitchConfig, prefs.drawerViewOrder, prefs.monthViewStyle, - ) { quickSwitch, drawer, monthStyle -> - ViewCustomization(quickSwitch, drawer, monthStyle) + prefs.widgetSize, + ) { quickSwitch, drawer, monthStyle, widgetSize -> + ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -186,6 +189,7 @@ class SettingsViewModel @Inject constructor( quickSwitchConfig = misc.viewCustomization.quickSwitch, drawerViewOrder = misc.viewCustomization.drawerOrder, monthViewStyle = misc.viewCustomization.monthViewStyle, + widgetSize = misc.viewCustomization.widgetSize, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -300,6 +304,7 @@ class SettingsViewModel @Inject constructor( val quickSwitch: QuickSwitchConfig, val drawerOrder: List, val monthViewStyle: MonthViewStyle, + val widgetSize: WidgetSize, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -478,6 +483,28 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } } + /** + * Set the size the agenda widget draws its text at (#51). Pushed into every + * instance's Glance state and recomposed — the same reliable-update path as + * [setAgendaWidgetRange], since `updateAll` alone won't re-run the data + * preamble a live session already ran. + * + * Agenda-only on purpose: the month widget sizes itself from the space it is + * given and always has, so it takes no size setting (#103). + */ + fun setWidgetSize(size: WidgetSize) { + viewModelScope.launch { + prefs.setWidgetSize(size) + widgetRefreshMutex.withLock { + val manager = GlanceAppWidgetManager(appContext) + manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> + updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name } + } + AgendaWidget().updateAll(appContext) + } + } + } + fun setAgendaShowToday(enabled: Boolean) { viewModelScope.launch { prefs.setAgendaShowToday(enabled) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index df0f4bb..0fa107e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -74,6 +74,8 @@ sealed interface AgendaWidgetData { * from Glance state before an instance has its own state set. */ val savedShowToday: Boolean, + /** Saved widget size step (#103) — the fallback before Glance state is set. */ + val savedWidgetSize: WidgetSize, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -145,6 +147,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { savedRange = savedRange, savedPastDisplay = savedPastDisplay, savedShowToday = showToday, + savedWidgetSize = prefs.widgetSize.first(), now = Clock.System.now(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt deleted file mode 100644 index 9b19179..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt +++ /dev/null @@ -1,70 +0,0 @@ -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/WidgetSize.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetSize.kt new file mode 100644 index 0000000..f03b0ab --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetSize.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.calendula.widget + +/** + * The size step the **agenda** widget draws its text and rows at — a user + * setting, not something derived from the widget's measured size (#51). + * + * The agenda widget used to bucket its live size (`SizeMode.Exact` + + * `LocalSize.current`) into a tier and scale from that. The size a launcher + * reports is not the size the widget is drawn into, so the tier it picked could + * disagree with what was on screen; a size the user sets is predictable and is + * what the #51 thread actually asked for. + * + * The month widget deliberately has no size setting: its grid divides whatever + * width it is given by seven and always has, which is the behaviour to keep + * (#103). + * + * [SMALL] is the default and reproduces the widget's original constants, so an + * existing widget looks as it did until its owner turns the size up. + */ +enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE } 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 index 1e55303..4e7540c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -4,12 +4,12 @@ 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 +import de.jeanlucmakiola.calendula.widget.WidgetSize /** * 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 + * size step — 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. */ @@ -21,23 +21,9 @@ internal val STRIPE_GAP = 10.dp 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. + * Every size the agenda widget varies by [WidgetSize]. 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" @@ -59,7 +45,7 @@ internal data class AgendaMetrics( * 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. + * and at the default scale of 1.0 reproduces the step's value exactly. */ fun scaledForFont(fontScale: Float): AgendaMetrics = if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) @@ -72,18 +58,18 @@ internal data class AgendaMetrics( * * 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. + * ‡ SMALL's 13sp day header is off-scale. It is held there deliberately — + * SMALL reproduces the widget's original constants verbatim so a widget whose + * owner never touches the size setting 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 + * which is far too coarse for four size steps. Where a role would force a + * ≥1.4x step between adjacent steps we hold an interpolated value instead and * mark it. The endpoints stay on real roles. */ -private val COMPACT_METRICS = AgendaMetrics( +private val SMALL_METRICS = AgendaMetrics( title = 16.sp, // M3 Title Medium dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline eventTitle = 14.sp, // M3 Body Medium @@ -97,7 +83,7 @@ private val COMPACT_METRICS = AgendaMetrics( dayHeaderTopPad = 10.dp, ) -private val REGULAR_METRICS = AgendaMetrics( +private val MEDIUM_METRICS = AgendaMetrics( title = 18.sp, // † dayHeader = 14.sp, // M3 Title Small eventTitle = 16.sp, // M3 Body Large @@ -125,7 +111,7 @@ private val LARGE_METRICS = AgendaMetrics( dayHeaderTopPad = 12.dp, ) -private val XLARGE_METRICS = AgendaMetrics( +private val EXTRA_LARGE_METRICS = AgendaMetrics( title = 22.sp, // M3 Title Large dayHeader = 18.sp, // † eventTitle = 20.sp, // † @@ -139,12 +125,12 @@ private val XLARGE_METRICS = AgendaMetrics( dayHeaderTopPad = 14.dp, ) -/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */ +/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */ private val AGENDA_METRICS = listOf( - COMPACT_METRICS, - REGULAR_METRICS, + SMALL_METRICS, + MEDIUM_METRICS, LARGE_METRICS, - XLARGE_METRICS, + EXTRA_LARGE_METRICS, ) -internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal] +internal fun metricsFor(size: WidgetSize): AgendaMetrics = AGENDA_METRICS[size.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 7a7f7d9..3130678 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 @@ -13,7 +13,6 @@ 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 @@ -62,7 +61,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.WidgetSize import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today @@ -105,22 +104,27 @@ internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display */ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today") +/** + * Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively + * in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the + * setting reflects on the live widget without depending on the `provideGlance` + * preamble re-running. + */ +internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size") + 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 + // Single: type and row metrics come from the user's chosen WidgetSize, not + // from the widget's measured size (#103, #51), so there is nothing to gain + // from Glance building one RemoteViews per host size bucket — and plenty to + // lose, since that roughly doubles the serialized payload. The row list is + // still capped below: an uncapped agenda (the range goes up to + // AgendaRange.MAX_CUSTOM_DAYS = 365) could push the RemoteViews past the + // binder transaction limit and the host would just show "Problem loading + // widget". + override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() @@ -160,12 +164,18 @@ 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). + // Type and row metrics come from the user's chosen size step, read reactively + // from per-instance Glance state and falling back to the saved pref for a + // freshly placed widget (#103, #51). The permission screen has no loaded prefs + // to fall back to, so it takes the default. // 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 savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL + val size = currentState(AGENDA_SIZE_KEY) + ?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } } + ?: savedSize val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale - val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale) + val metrics = metricsFor(size).scaledForFont(fontScale) Column( modifier = GlanceModifier .fillMaxSize() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18fe3ab..10d5521 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -354,6 +354,13 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + + Agenda widget size + How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it. + Small + Medium + Large + Extra large Always show today Keep today at the top of the agenda and its widget, even once nothing is left today. Range bar diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt deleted file mode 100644 index 287400c..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -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 index edc84c4..80d64f9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -1,11 +1,9 @@ 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 de.jeanlucmakiola.calendula.widget.WidgetSize import org.junit.jupiter.api.Test class AgendaScaleTest { @@ -13,23 +11,10 @@ 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) + fun `SMALL metrics equal the widget's original constants`() { + // SMALL is the default, so if this fails an agenda widget whose owner never + // touched the size setting no longer looks as it did (#51). + val m = metricsFor(WidgetSize.SMALL) assertThat(m.title).isEqualTo(16.sp) assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.eventTitle).isEqualTo(14.sp) @@ -54,10 +39,10 @@ class AgendaScaleTest { // --- the ramp ------------------------------------------------------------ @Test - fun `sizes are non-decreasing across the tiers`() { - val tiers = WidgetScale.entries.map(::metricsFor) + fun `sizes are non-decreasing across the steps`() { + val steps = WidgetSize.entries.map(::metricsFor) - tiers.zipWithNext { small, big -> + steps.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) @@ -76,16 +61,16 @@ class AgendaScaleTest { 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 -> + WidgetSize.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`() { + fun `no step 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) + val base = metricsFor(WidgetSize.SMALL) + val top = metricsFor(WidgetSize.EXTRA_LARGE) assertThat(top.title.value / base.title.value).isLessThan(1.5f) assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f) } @@ -96,7 +81,7 @@ class AgendaScaleTest { 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) + val m = metricsFor(WidgetSize.SMALL) 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) @@ -104,7 +89,7 @@ class AgendaScaleTest { @Test fun `scaling for the default font scale changes nothing`() { - val m = metricsFor(WidgetScale.LARGE) + val m = metricsFor(WidgetSize.LARGE) assertThat(m.scaledForFont(1f)).isSameInstanceAs(m) } @@ -112,7 +97,7 @@ class AgendaScaleTest { 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 m = metricsFor(WidgetSize.MEDIUM) val scaled = m.scaledForFont(1.3f) assertThat(scaled.title).isEqualTo(m.title) assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)