Draw widgets at a size you pick, not a measured one (#103, #51) (#104)

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/104
This commit is contained in:
Jean-Luc Makiola
2026-07-30 19:22:35 +02:00
parent e44d70e18b
commit 02cf32b537
13 changed files with 187 additions and 248 deletions

View File

@@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda
widget size** offers Small, Medium, Large and Extra large, replacing the guess
the widget used to make from its own measurements. Small is what it looks like
today, so nothing changes until you turn it up ([#51]).
### Changed
- Calendula's source code now lives on **Codeberg**, where its issues already
@@ -37,11 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unaffected.
### Fixed
- A month-grid widget stays a month grid. Since 2.16.0 a placed month widget
could redraw itself as the agenda widget a little after any change to your
events, because the release build merged the two widgets into a single class
and Android could no longer tell which of them a widget on your home screen
was ([#89]).
- A month-grid widget stays a month grid, and draws all seven days again. Since
2.16.0 a placed month widget could redraw itself as the agenda widget a little
after any change to your events, and could draw only about four day columns
with the last one cut off part-way through. Both came from the release build
merging the two widgets into a single class, so Android could no longer tell
which of them a widget on your home screen was — and the month grid was handed
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
- The back gesture on **Settings → Views** returns to Settings instead of
leaving Settings altogether and dropping you on the calendar. Special dates
did the same ([#81]).
@@ -1216,3 +1222,4 @@ automatically, with zero telemetry and no internet permission.
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103

View File

@@ -23,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -306,6 +307,23 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
}
/**
* The size step the agenda widget draws its text at (#51). Defaults to
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
* widget is unchanged until its owner turns the size up.
*
* This replaced deriving a size tier from the widget's measured size, which
* the launcher does not report reliably. The month widget takes no size
* setting — it divides the width it is given by seven (#103).
*/
val widgetSize: Flow<WidgetSize> = 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")

View File

@@ -117,6 +117,7 @@ import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -150,6 +151,7 @@ import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.calendula.widget.WidgetSize
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime
import java.time.format.TextStyle as JavaTextStyle
@@ -499,6 +501,7 @@ private fun AppearanceScreen(
var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) }
@@ -684,6 +687,12 @@ private fun AppearanceScreen(
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow(
title = stringResource(R.string.settings_widget_size),
summary = widgetSizeLabel(state.widgetSize),
position = Position.Middle,
onClick = { showWidgetSize = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
@@ -810,6 +819,18 @@ private fun AppearanceScreen(
onDismiss = { showAgendaWidgetRange = false },
)
}
if (showWidgetSize) {
OptionPicker(
title = stringResource(R.string.settings_widget_size),
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
predictiveBack = true,
options = WidgetSize.entries,
selected = state.widgetSize,
label = { widgetSizeLabel(it) },
onSelect = viewModel::setWidgetSize,
onDismiss = { showWidgetSize = false },
)
}
if (showTimeFormat) {
OptionPicker(
title = stringResource(R.string.settings_time_format),
@@ -2072,6 +2093,16 @@ private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResour
},
)
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -48,6 +49,8 @@ data class SettingsUiState(
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The size step the agenda widget draws its text at (#51). */
val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */

View File

@@ -39,9 +39,11 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
@@ -162,8 +164,9 @@ class SettingsViewModel @Inject constructor(
prefs.quickSwitchConfig,
prefs.drawerViewOrder,
prefs.monthViewStyle,
) { quickSwitch, drawer, monthStyle ->
ViewCustomization(quickSwitch, drawer, monthStyle)
prefs.widgetSize,
) { quickSwitch, drawer, monthStyle, widgetSize ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -186,6 +189,7 @@ class SettingsViewModel @Inject constructor(
quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder,
monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -300,6 +304,7 @@ class SettingsViewModel @Inject constructor(
val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>,
val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
@@ -478,6 +483,28 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
/**
* Set the size the agenda widget draws its text at (#51). Pushed into every
* instance's Glance state and recomposed — the same reliable-update path as
* [setAgendaWidgetRange], since `updateAll` alone won't re-run the data
* preamble a live session already ran.
*
* Agenda-only on purpose: the month widget sizes itself from the space it is
* given and always has, so it takes no size setting (#103).
*/
fun setWidgetSize(size: WidgetSize) {
viewModelScope.launch {
prefs.setWidgetSize(size)
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)

View File

@@ -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(),
)
}

View File

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

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.widget
/**
* The size step the **agenda** widget draws its text and rows at — a user
* setting, not something derived from the widget's measured size (#51).
*
* The agenda widget used to bucket its live size (`SizeMode.Exact` +
* `LocalSize.current`) into a tier and scale from that. The size a launcher
* reports is not the size the widget is drawn into, so the tier it picked could
* disagree with what was on screen; a size the user sets is predictable and is
* what the #51 thread actually asked for.
*
* The month widget deliberately has no size setting: its grid divides whatever
* width it is given by seven and always has, which is the behaviour to keep
* (#103).
*
* [SMALL] is the default and reproduces the widget's original constants, so an
* existing widget looks as it did until its owner turns the size up.
*/
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }

View File

@@ -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]

View File

@@ -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()

View File

@@ -354,6 +354,13 @@
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_widget_size">Agenda widget size</string>
<string name="settings_widget_size_hint">How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it.</string>
<string name="settings_widget_size_small">Small</string>
<string name="settings_widget_size_medium">Medium</string>
<string name="settings_widget_size_large">Large</string>
<string name="settings_widget_size_extra_large">Extra large</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_range_bar">Range bar</string>

View File

@@ -1,89 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetScaleTest {
@Test
fun `the on-device calibration points map to their tiers`() {
// The two sizes measured on-device: the compact widget stays COMPACT (the
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
// which read as too big on a phone (#51).
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
}
@Test
fun `a full-width widget at ordinary height still scales up`() {
// The regression the height cap used to cause: widening the widget without
// also making it unusually tall is *the* resize #51 reports, and it must
// reach the tier its width earned. Three cells tall is about 270dp.
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `width buckets into the four tiers`() {
// Tall enough that the height cap never binds, isolating the width rule.
val h = 500.dp
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `extra height never raises the tier`() {
// Height decides how many rows are visible, not how big they are: a tall,
// narrow widget wants more events, not bigger text.
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
}
@Test
fun `only a genuinely squashed widget is stepped back down`() {
// Same (wide) width, shrinking height. The cap exists to stop oversized type
// surviving in a one- or two-row sliver — it must not fire at normal heights.
// Heights are gross; the cap works on height minus 60dp of chrome, so the
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
val wide = 378.dp
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
}
@Test
fun `the tier is monotonic in both axes`() {
// Growing a widget must never make its type smaller. Sweeps the whole
// plausible range rather than spot-checking, so a future threshold edit
// can't accidentally invert a step.
val widths = (110..900 step 7).map { it.dp }
val heights = (110..900 step 7).map { it.dp }
widths.forEach { w ->
heights.zipWithNext { shorter, taller ->
assertThat(scaleFor(DpSize(w, taller)))
.isAtLeast(scaleFor(DpSize(w, shorter)))
}
}
heights.forEach { h ->
widths.zipWithNext { narrower, wider ->
assertThat(scaleFor(DpSize(wider, h)))
.isAtLeast(scaleFor(DpSize(narrower, h)))
}
}
}
}

View File

@@ -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)