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) + } +}