From 2f46eba4fa0825e25e22e5797dbcd3c7cc67583b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 16:37:47 +0200 Subject: [PATCH] fix(widget): draw widgets at a size you pick, not a measured one (#103, #51) The month grid sized its seven day columns from the width the launcher reports, which is not the width the widget is drawn into. The columns came out too wide, so only about four fitted and the last one was cut off. Both widgets now draw at a WidgetSize the user picks in Settings, and nothing reads LocalSize any more (SizeMode.Single for both). The month grid lays its columns out at a fixed width and centres them, which also keeps a multi-day event one connected bar; minResizeWidth is raised so the smallest step always fits. Closes #103 Closes #51 --- CHANGELOG.md | 14 ++ .../calendula/data/prefs/SettingsPrefs.kt | 19 ++ .../calendula/ui/settings/SettingsScreen.kt | 37 ++++ .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 33 ++- .../calendula/widget/WidgetData.kt | 3 + .../calendula/widget/WidgetScale.kt | 70 ------- .../calendula/widget/WidgetSize.kt | 18 ++ .../calendula/widget/agenda/AgendaScale.kt | 56 ++--- .../calendula/widget/agenda/AgendaWidget.kt | 44 ++-- .../calendula/widget/month/MonthScale.kt | 114 +++++++++++ .../calendula/widget/month/MonthWidget.kt | 191 ++++++++++++------ app/src/main/res/values/strings.xml | 8 + app/src/main/res/xml/appwidget_info_month.xml | 4 +- .../calendula/widget/WidgetScaleTest.kt | 89 -------- .../widget/agenda/AgendaScaleTest.kt | 45 ++--- .../calendula/widget/month/MonthScaleTest.kt | 96 +++++++++ 17 files changed, 541 insertions(+), 303 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 create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthScale.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthScaleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index db008f1..f3e3d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ 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]). +- Both home-screen widgets have a size you set yourself. **Settings → Widgets → + Widget size** offers Small, Medium, Large and Extra large: it sets how big the + agenda widget's text is, and how big the month widget's day columns and text + are. Small is what the widgets look like today, so nothing changes until you + turn it up ([#51], [#103]). ### Changed - Calendula's source code now lives on **Codeberg**, where its issues already @@ -37,6 +42,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unaffected. ### Fixed +- The month widget shows all seven days again. Unless it happened to be resized + to just the right shape, it drew only about four columns and cut the last one + off part-way through — the rest of the week was simply missing. The widget was + sizing its columns from the width the launcher reported, which is not the width + it is actually drawn into, so the columns came out too wide to fit. Nothing in + either widget is measured any more: they draw at the size you pick under + Settings → Widgets. The month widget can no longer be resized narrower than its + grid needs, either ([#103], [#51]). - Search results now show an all-day event's real date. West of UTC — anywhere in the Americas, say — a search hit was dated one day early, disagreeing with the day the month, week and agenda views file the same event under ([#82]). @@ -1170,3 +1183,4 @@ automatically, with zero telemetry and no internet permission. [#76]: https://codeberg.org/jlmakiola/calendula/issues/76 [#78]: https://codeberg.org/jlmakiola/calendula/issues/78 [#82]: https://codeberg.org/jlmakiola/calendula/issues/82 +[#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..257e613 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 both home-screen widgets draw themselves at (#103, #51). + * Defaults to [WidgetSize.SMALL], which reproduces their 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: the + * width a launcher reports is not the width the widget is drawn into, and the + * month grid sized its seven columns from it and lost the last three. + */ + 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 c1efe32..d0dfd8e 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) } @@ -699,6 +702,18 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) + // Widgets — the size step applies to both home-screen widgets, so it sits + // in its own group rather than under Agenda. + SectionHeader(stringResource(R.string.settings_widgets_header)) + GroupedRow( + title = stringResource(R.string.settings_widget_size), + summary = widgetSizeLabel(state.widgetSize), + position = Position.Alone, + onClick = { showWidgetSize = true }, + ) + + Spacer(Modifier.height(16.dp)) + // App name — chooses the launcher label between "Calendula" and "Calendar" // (issue #44). Own group: it's a launcher/system concern, not calendar // formatting. A sub-page chooser (not a switch), matching the app's other @@ -810,6 +825,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), @@ -2070,6 +2097,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..557457b 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 both home-screen widgets draw themselves at (#103, #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 9adcf3a..d731e35 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 @@ -38,10 +38,13 @@ 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.MONTH_SIZE_KEY import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -160,8 +163,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) @@ -184,6 +188,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, @@ -298,6 +303,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. */ @@ -476,6 +482,29 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } } + /** + * Set the size step both widgets draw at (#103, #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. + */ + 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 } + } + manager.getGlanceIds(MonthWidget::class.java).forEach { id -> + updateAppWidgetState(appContext, id) { it[MONTH_SIZE_KEY] = size.name } + } + AgendaWidget().updateAll(appContext) + MonthWidget().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..730de5b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetSize.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.calendula.widget + +/** + * The size step a widget draws itself at — a **user setting**, not something + * derived from the widget's measured size (#103, #51). + * + * Both Glance widgets used to bucket their live size (`SizeMode.Exact` + + * `LocalSize.current`) into a tier and scale from that. The size a launcher + * reports is not the width the widget is actually drawn into, which broke the + * month grid outright: its seven columns were sized from that number, came out + * too wide, and only the first four fitted. Nothing here reads a measured size + * any more — the user picks a step, every metric follows from it, and both + * widgets declare `SizeMode.Single`. + * + * [SMALL] is the default and reproduces the widgets' 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/java/de/jeanlucmakiola/calendula/widget/month/MonthScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthScale.kt new file mode 100644 index 0000000..a22346d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthScale.kt @@ -0,0 +1,114 @@ +package de.jeanlucmakiola.calendula.widget.month + +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.WidgetSize + +/** Event rows (lanes) shown per week before the rest collapse into "+N". */ +internal const val MAX_LANES = 3 + +/** + * Every size the month widget draws from, resolved from the user's chosen + * [WidgetSize]. + * + * [columnWidth] is the load-bearing one. The grid lays its seven day columns out + * at this fixed width and centres the result in whatever space the host gives it, + * rather than dividing a launcher-reported width by seven (#103) — a reported + * width that overshoots the real one used to push the last columns off the edge. + * A fixed column also keeps a multi-day event a single connected bar + * `columnWidth * n` wide, which is why the columns can't simply be weighted. + */ +internal data class MonthMetrics( + val columnWidth: Dp, // one day column + val gridPadding: Dp, // horizontal padding either side of the grid + val laneHeight: Dp, // one event-bar row + val dayNumberHeight: Dp, // day-number row, and the today circle's diameter + val headerTitle: TextUnit, + val weekday: TextUnit, // narrow weekday initials + val dayNumber: TextUnit, + val eventTitle: TextUnit, // title inside an event bar + val overflow: TextUnit, // the "+N" line + val iconImage: Dp, // header arrow/today glyph + val iconBox: Dp, // header action touch target +) { + /** Width the seven columns plus their padding occupy — what a placement must fit. */ + val gridWidth: Dp get() = columnWidth * 7 + gridPadding * 2 +} + +/* + * SMALL reproduces the widget's original constants so an existing widget is + * unchanged until its owner turns the size up — except [columnWidth], which had + * no fixed value before (it was derived from the reported width, at roughly 33dp + * on a default 4-cell placement). 30dp sits just under that so the smallest step + * still fits the narrowest placement the provider allows; see + * `appwidget_info_month.xml`'s minResizeWidth, which is pinned against + * SMALL.gridWidth by a test. + */ + +private val SMALL_METRICS = MonthMetrics( + columnWidth = 30.dp, + gridPadding = 4.dp, + laneHeight = 14.dp, + dayNumberHeight = 18.dp, + headerTitle = 15.sp, + weekday = 11.sp, + dayNumber = 11.sp, + eventTitle = 9.sp, + overflow = 9.sp, + iconImage = 20.dp, + iconBox = 40.dp, +) + +private val MEDIUM_METRICS = MonthMetrics( + columnWidth = 38.dp, + gridPadding = 6.dp, + laneHeight = 16.dp, + dayNumberHeight = 22.dp, + headerTitle = 17.sp, + weekday = 12.sp, + dayNumber = 13.sp, + eventTitle = 10.sp, + overflow = 10.sp, + iconImage = 22.dp, + iconBox = 44.dp, +) + +private val LARGE_METRICS = MonthMetrics( + columnWidth = 46.dp, + gridPadding = 8.dp, + laneHeight = 19.dp, + dayNumberHeight = 26.dp, + headerTitle = 19.sp, + weekday = 13.sp, + dayNumber = 15.sp, + eventTitle = 11.sp, + overflow = 11.sp, + iconImage = 24.dp, + iconBox = 48.dp, +) + +private val EXTRA_LARGE_METRICS = MonthMetrics( + columnWidth = 54.dp, + gridPadding = 8.dp, + laneHeight = 22.dp, + dayNumberHeight = 30.dp, + headerTitle = 22.sp, + weekday = 14.sp, + dayNumber = 17.sp, + eventTitle = 12.sp, + overflow = 12.sp, + iconImage = 26.dp, + iconBox = 52.dp, +) + +/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */ +private val MONTH_METRICS = listOf( + SMALL_METRICS, + MEDIUM_METRICS, + LARGE_METRICS, + EXTRA_LARGE_METRICS, +) + +internal fun monthMetricsFor(size: WidgetSize): MonthMetrics = MONTH_METRICS[size.ordinal] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 24f19a3..6550389 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -13,7 +14,6 @@ import androidx.glance.GlanceTheme import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.LocalContext -import androidx.glance.LocalSize import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable @@ -56,6 +56,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.MonthWidgetSource +import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today @@ -73,11 +74,13 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") -/** Event rows (lanes) shown per week before the rest collapse into "+N". */ -private const val MAX_LANES = 3 -private val LANE_HEIGHT = 14.dp -private val DAY_NUMBER_HEIGHT = 18.dp -private val GRID_HPADDING = 8.dp +/** + * Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively + * in the composition so changing the setting reflects on a live widget by plain + * recomposition — `updateAll` does not reliably re-run the `provideGlance` + * preamble for a live session (same reason as [MONTH_INDEX_KEY]). + */ +internal val MONTH_SIZE_KEY = stringPreferencesKey("widget_size") private fun currentMonthIndex(zone: TimeZone): Int { val t = today(zone) @@ -92,27 +95,45 @@ private fun yearMonthOf(index: Int): YearMonth = * event bars and titled single-day pills (the in-app lane layout via * [layoutMonthWeeks]), and prev/next/today navigation. * - * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a - * multi-day span renders as a single Box spanning its columns — connected, no - * inter-cell seam, with rounded end caps. The displayed month lives in Glance - * state and is read reactively in the composition ([currentState]) so the arrows - * move it via plain recomposition, not a (here-unreliable) widget session reload. + * Columns are a fixed [MonthMetrics.columnWidth] wide — the user's chosen + * [WidgetSize], never a measured size — and the grid is centred in whatever space + * the host gives it. Sizing the columns off the launcher-reported width is what + * broke the grid (#103): the reported width overshot the width actually drawn + * into, so `width / 7` came out too wide and only the first four columns fitted. + * A fixed column also keeps a multi-day event one connected Box spanning its + * columns — no inter-cell seam, rounded end caps — which weighted columns, the + * other way to be measurement-free, could not express. + * + * The displayed month lives in Glance state and is read reactively in the + * composition ([currentState]) so the arrows move it via plain recomposition, not + * a (here-unreliable) widget session reload. */ class MonthWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - override val sizeMode = SizeMode.Exact + + // Single, not Exact: nothing in the layout depends on the widget's measured + // size any more (#103), so there is no reason to pay for one RemoteViews per + // host size bucket. + override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { val source = context.loadMonthWidgetSource() val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - // Read fresh (not through the cached source) so toggling the softener - // redraws with the new choice; it's one cheap DataStore read. - val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first() + // Read fresh (not through the cached source) so toggling the softener or + // the size redraws with the new choice; two cheap DataStore reads. + val prefs = context.widgetEntryPoint().settingsPrefs() + val soften = prefs.softenCalendarColors.first() + val savedSize = prefs.widgetSize.first() provideContent { CalendulaGlanceTheme { - MonthWidgetBody(source = source, dark = dark, soften = soften) + MonthWidgetBody( + source = source, + dark = dark, + soften = soften, + savedSize = savedSize, + ) } } } @@ -143,29 +164,35 @@ class ResetMonthAction : ActionCallback { } @Composable -private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) { +private fun MonthWidgetBody( + source: MonthWidgetSource, + dark: Boolean, + soften: Boolean, + savedSize: WidgetSize, +) { + // Size read reactively from per-instance Glance state (falling back to the + // saved pref for a freshly placed widget), never from the measured size. + val metrics = monthMetricsFor(currentState(MONTH_SIZE_KEY).toWidgetSize(savedSize)) Column( modifier = GlanceModifier .fillMaxSize() .background(GlanceTheme.colors.surface) - .padding(horizontal = GRID_HPADDING, vertical = 6.dp), + .padding(horizontal = metrics.gridPadding, vertical = 6.dp), ) { when (source) { MonthWidgetSource.NeedsPermission -> { - MonthHeader(label = "Calendula") + MonthHeader(label = "Calendula", metrics = metrics) PermissionMessage() } is MonthWidgetSource.Ready -> { val zone = systemZone() val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val ym = yearMonthOf(index) - // Column width from the live widget size, minus our H padding. - val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - MonthHeader(label = monthLabel(ym, source.today.year)) + MonthHeader(label = monthLabel(ym, source.today.year), metrics = metrics) Spacer(GlanceModifier.height(2.dp)) - WeekdayHeader(weekStart = source.weekStart, colW = colW) + WeekdayHeader(weekStart = source.weekStart, metrics = metrics) weeks.forEach { week -> WeekRow( week = week, @@ -173,7 +200,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo today = source.today, dark = dark, soften = soften, - colW = colW, + metrics = metrics, modifier = GlanceModifier.defaultWeight(), ) } @@ -182,8 +209,26 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo } } +/** + * A grid row: the seven fixed-width columns, centred in the space the host gave + * us. Every row of the grid goes through this so they can never drift out of + * alignment, and centring means leftover width shows as an even margin either + * side rather than a ragged edge (turn the size up to fill it). + */ @Composable -private fun MonthHeader(label: String) { +private fun GridRow(content: @Composable androidx.glance.layout.RowScope.() -> Unit) { + Row( + modifier = GlanceModifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +private fun String?.toWidgetSize(default: WidgetSize): WidgetSize = + this?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } } ?: default + +@Composable +private fun MonthHeader(label: String, metrics: MonthMetrics) { val context = LocalContext.current Row( modifier = GlanceModifier.fillMaxWidth(), @@ -192,6 +237,7 @@ private fun MonthHeader(label: String) { HeaderIcon( resId = R.drawable.ic_widget_chevron_left, contentDescription = context.getString(R.string.widget_prev_month), + metrics = metrics, onClick = GlanceModifier.clickable( actionRunCallback( actionParametersOf(ShiftMonthAction.deltaKey to -1), @@ -204,7 +250,7 @@ private fun MonthHeader(label: String) { text = label, style = TextStyle( color = GlanceTheme.colors.primary, - fontSize = 15.sp, + fontSize = metrics.headerTitle, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, ), @@ -220,11 +266,13 @@ private fun MonthHeader(label: String) { HeaderIcon( resId = R.drawable.ic_widget_today, contentDescription = context.getString(R.string.widget_today), + metrics = metrics, onClick = GlanceModifier.clickable(actionRunCallback()), ) HeaderIcon( resId = R.drawable.ic_widget_chevron_right, contentDescription = context.getString(R.string.widget_next_month), + metrics = metrics, onClick = GlanceModifier.clickable( actionRunCallback( actionParametersOf(ShiftMonthAction.deltaKey to 1), @@ -235,32 +283,37 @@ private fun MonthHeader(label: String) { } @Composable -private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) { +private fun HeaderIcon( + resId: Int, + contentDescription: String, + metrics: MonthMetrics, + onClick: GlanceModifier, +) { 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(20.dp), + modifier = GlanceModifier.size(metrics.iconImage), ) } } @Composable -private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { +private fun WeekdayHeader(weekStart: DayOfWeek, metrics: MonthMetrics) { + GridRow { weekdayNarrowNames(weekStart).forEach { name -> Text( text = name, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, + fontSize = metrics.weekday, textAlign = TextAlign.Center, ), - modifier = GlanceModifier.width(colW), + modifier = GlanceModifier.width(metrics.columnWidth), ) } } @@ -283,29 +336,30 @@ private fun WeekRow( today: LocalDate, dark: Boolean, soften: Boolean, - colW: Dp, + metrics: MonthMetrics, modifier: GlanceModifier, ) { Column(modifier = modifier.fillMaxWidth()) { // Day numbers. - Row(modifier = GlanceModifier.fillMaxWidth()) { + GridRow { week.days.forEach { date -> DayNumber( date = date, isToday = date == today, inMonth = date.month == currentMonth, - colW = colW, + metrics = metrics, ) } } Spacer(GlanceModifier.height(2.dp)) // One lane row per event row. A multi-day span is a single Box spanning - // its columns (colW * n) so it's connected with no seam and rounded ends. + // its columns (columnWidth * n) so it's connected with no seam and + // rounded ends. repeat(MAX_LANES) { lane -> - LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) + LaneRow(week = week, lane = lane, dark = dark, soften = soften, metrics = metrics) Spacer(GlanceModifier.height(1.dp)) } - OverflowRow(week = week, colW = colW) + OverflowRow(week = week, metrics = metrics) } } @@ -319,19 +373,20 @@ private fun openDayAction(context: Context, date: LocalDate) = actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) @Composable -private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { +private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, metrics: MonthMetrics) { val context = LocalContext.current + val diameter = metrics.dayNumberHeight Box( modifier = GlanceModifier - .width(colW) - .height(DAY_NUMBER_HEIGHT) + .width(metrics.columnWidth) + .height(diameter) .clickable(openDayAction(context, date)), contentAlignment = Alignment.Center, ) { Box( modifier = GlanceModifier - .size(DAY_NUMBER_HEIGHT) - .then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier), + .size(diameter) + .then(if (isToday) GlanceModifier.cornerRadius(diameter / 2).background(GlanceTheme.colors.primary) else GlanceModifier), contentAlignment = Alignment.Center, ) { Text( @@ -342,7 +397,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: inMonth -> GlanceTheme.colors.onSurface else -> GlanceTheme.colors.onSurfaceVariant }, - fontSize = 11.sp, + fontSize = metrics.dayNumber, fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, ), ) @@ -351,27 +406,39 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: } @Composable -private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { +private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, metrics: MonthMetrics) { val context = LocalContext.current - Row(modifier = GlanceModifier.fillMaxWidth()) { + GridRow { var col = 0 while (col < 7) { val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } if (span != null) { val cols = span.endCol - col + 1 - SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols) + SpanBar( + event = span.event, + dark = dark, + soften = soften, + width = metrics.columnWidth * cols, + metrics = metrics, + ) col = span.endCol + 1 } else { val timed = timedEventAt(week, lane, col, week.days[col]) if (timed != null) { - SpanBar(event = timed, dark = dark, soften = soften, width = colW) + SpanBar( + event = timed, + dark = dark, + soften = soften, + width = metrics.columnWidth, + metrics = metrics, + ) } else { // Empty lane cell: a tap opens that day, so blank space in a // day column is a day-open target just like the number is. Box( GlanceModifier - .width(colW) - .height(LANE_HEIGHT) + .width(metrics.columnWidth) + .height(metrics.laneHeight) .clickable(openDayAction(context, week.days[col])), ) {} } @@ -383,13 +450,19 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, /** A single connected, rounded event bar [width] wide with its clipped title. */ @Composable -private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) { +private fun SpanBar( + event: EventInstance, + dark: Boolean, + soften: Boolean, + width: Dp, + metrics: MonthMetrics, +) { val context = LocalContext.current val fill = eventFill(event.color, dark, soften) Box( modifier = GlanceModifier .width(width) - .height(LANE_HEIGHT) + .height(metrics.laneHeight) .padding(horizontal = 1.dp) // Tap an event bar to open its detail, rooted in the month view. .clickable( @@ -414,7 +487,7 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, - style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp), + style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = metrics.eventTitle), modifier = GlanceModifier.padding(horizontal = 3.dp), ) } @@ -422,9 +495,9 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: } @Composable -private fun OverflowRow(week: MonthWeek, colW: Dp) { +private fun OverflowRow(week: MonthWeek, metrics: MonthMetrics) { val context = LocalContext.current - Row(modifier = GlanceModifier.fillMaxWidth()) { + GridRow { week.days.forEachIndexed { col, date -> val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) @@ -434,8 +507,8 @@ private fun OverflowRow(week: MonthWeek, colW: Dp) { // it shows "+N" or is blank) opens that day, same as the app. Box( modifier = GlanceModifier - .width(colW) - .height(LANE_HEIGHT) + .width(metrics.columnWidth) + .height(metrics.laneHeight) .clickable(openDayAction(context, date)), contentAlignment = Alignment.CenterStart, ) { @@ -443,7 +516,7 @@ private fun OverflowRow(week: MonthWeek, colW: Dp) { Text( text = "+$hidden", maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.overflow), modifier = GlanceModifier.padding(start = 3.dp), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21c16fc..a38e116 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -354,6 +354,14 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + + Widgets + Widget size + How large both home-screen widgets draw their text and, for the month widget, its day columns. Pick a bigger size to fill a bigger widget. + 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/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml index cfa7acb..05094c8 100644 --- a/app/src/main/res/xml/appwidget_info_month.xml +++ b/app/src/main/res/xml/appwidget_info_month.xml @@ -1,10 +1,12 @@ + - 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) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthScaleTest.kt new file mode 100644 index 0000000..c07649e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthScaleTest.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.calendula.widget.month + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.widget.WidgetSize +import org.junit.jupiter.api.Test + +class MonthScaleTest { + + /** + * `appwidget_info_month.xml`'s `minResizeWidth` — the narrowest the host will + * let a month widget be resized to. Mirrored here because the smallest size + * step has to fit inside it; the two move together or the grid clips again. + */ + private val MIN_RESIZE_WIDTH = 250.dp + + // --- the #103 guarantee: seven columns always fit ------------------------- + + @Test + fun `the smallest step fits the narrowest placement the provider allows`() { + // This is the whole fix. The grid is laid out at a fixed width, so the + // default step must fit the narrowest widget a user can drag it down to — + // otherwise columns fall off the edge exactly as they did when the width + // came from the launcher (#103). The slack covers host chrome, which is + // not part of the declared size. + val small = monthMetricsFor(WidgetSize.SMALL).gridWidth + assertThat(small.value).isLessThan(MIN_RESIZE_WIDTH.value) + assertThat((MIN_RESIZE_WIDTH - small).value).isAtLeast(24f) + } + + @Test + fun `every step lays out exactly seven columns plus its padding`() { + WidgetSize.entries.map(::monthMetricsFor).forEach { m -> + assertThat(m.gridWidth).isEqualTo(m.columnWidth * 7 + m.gridPadding * 2) + } + } + + @Test + fun `the largest step still fits a full-width phone widget`() { + // EXTRA_LARGE is opt-in, but it should be reachable on a normal handset + // rather than being a tablet-only trap. + assertThat(monthMetricsFor(WidgetSize.EXTRA_LARGE).gridWidth.value).isAtMost(400f) + } + + // --- the ramp ------------------------------------------------------------ + + @Test + fun `sizes are non-decreasing across the steps`() { + monthMetricsFor(WidgetSize.SMALL) + WidgetSize.entries.map(::monthMetricsFor).zipWithNext { small, big -> + assertThat(big.columnWidth.value).isAtLeast(small.columnWidth.value) + assertThat(big.laneHeight.value).isAtLeast(small.laneHeight.value) + assertThat(big.dayNumberHeight.value).isAtLeast(small.dayNumberHeight.value) + assertThat(big.headerTitle.value).isAtLeast(small.headerTitle.value) + assertThat(big.weekday.value).isAtLeast(small.weekday.value) + assertThat(big.dayNumber.value).isAtLeast(small.dayNumber.value) + assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value) + assertThat(big.overflow.value).isAtLeast(small.overflow.value) + assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) + assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) + } + } + + @Test + fun `the today circle always fits inside its column`() { + // The day number sits in a circle dayNumberHeight across, centred in a + // column. If it ever outgrew the column it would collide with its + // neighbours. + WidgetSize.entries.map(::monthMetricsFor).forEach { m -> + assertThat(m.dayNumberHeight.value).isLessThan(m.columnWidth.value) + } + } + + @Test + fun `SMALL keeps the widget's original row metrics`() { + // SMALL is the default, so a month widget whose owner never touched the + // setting must keep the metrics it shipped with. + val m = monthMetricsFor(WidgetSize.SMALL) + assertThat(m.laneHeight).isEqualTo(14.dp) + assertThat(m.dayNumberHeight).isEqualTo(18.dp) + assertThat(m.headerTitle.value).isEqualTo(15f) + assertThat(m.weekday.value).isEqualTo(11f) + assertThat(m.dayNumber.value).isEqualTo(11f) + assertThat(m.eventTitle.value).isEqualTo(9f) + assertThat(m.overflow.value).isEqualTo(9f) + } + + @Test + fun `three lanes plus the day number stay inside a compact week row`() { + // A week row gets roughly a sixth of the grid's height. If the metrics + // outgrew that, lanes would be clipped rather than merely tight. + val m = monthMetricsFor(WidgetSize.SMALL) + val weekRow = m.dayNumberHeight + 2.dp + (m.laneHeight + 1.dp) * MAX_LANES + m.laneHeight + assertThat(weekRow.value).isLessThan(90f) + } +}