fix(widget): draw widgets at a size you pick, not a measured one (#103, #51)

The month grid sized its seven day columns from the width the launcher
reports, which is not the width the widget is drawn into. The columns came
out too wide, so only about four fitted and the last one was cut off.

Both widgets now draw at a WidgetSize the user picks in Settings, and
nothing reads LocalSize any more (SizeMode.Single for both). The month grid
lays its columns out at a fixed width and centres them, which also keeps a
multi-day event one connected bar; minResizeWidth is raised so the smallest
step always fits.

Closes #103
Closes #51
This commit is contained in:
2026-07-30 16:37:47 +02:00
parent 1d07b64a28
commit 2f46eba4fa
17 changed files with 541 additions and 303 deletions

View File

@@ -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 - 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 with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]). 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 ### Changed
- Calendula's source code now lives on **Codeberg**, where its issues already - 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. unaffected.
### Fixed ### 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 - 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 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]). 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 [#76]: https://codeberg.org/jlmakiola/calendula/issues/76
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78 [#78]: https://codeberg.org/jlmakiola/calendula/issues/78
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82 [#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#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.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import java.time.ZoneId import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -306,6 +307,23 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } 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<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 * Whether the agenda shows its top range bar — the "showing …" header and
* the session range switcher (v2.11). Default ON. * 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_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_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 AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
internal val AGENDA_SHOW_TODAY_KEY = internal val AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today") booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") 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.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView 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_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.calendula.widget.WidgetSize
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -499,6 +501,7 @@ private fun AppearanceScreen(
var showDefaultView by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) } var showPlainFont by remember { mutableStateOf(false) }
@@ -699,6 +702,18 @@ private fun AppearanceScreen(
Spacer(Modifier.height(16.dp)) 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" // App name — chooses the launcher label between "Calendula" and "Calendar"
// (issue #44). Own group: it's a launcher/system concern, not 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 // formatting. A sub-page chooser (not a switch), matching the app's other
@@ -810,6 +825,18 @@ private fun AppearanceScreen(
onDismiss = { showAgendaWidgetRange = false }, 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) { if (showTimeFormat) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_time_format), 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 @Composable
private fun languageLabel(tag: String?): String = private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) 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.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -48,6 +49,8 @@ data class SettingsUiState(
val agendaShowToday: Boolean = true, val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */ /** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true, 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). */ /** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week, val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */ /** Which views the top-bar quick-switch button cycles through, and their order (#24). */

View File

@@ -38,10 +38,13 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN 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_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_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_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.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MONTH_SIZE_KEY
import de.jeanlucmakiola.calendula.widget.month.MonthWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -160,8 +163,9 @@ class SettingsViewModel @Inject constructor(
prefs.quickSwitchConfig, prefs.quickSwitchConfig,
prefs.drawerViewOrder, prefs.drawerViewOrder,
prefs.monthViewStyle, prefs.monthViewStyle,
) { quickSwitch, drawer, monthStyle -> prefs.widgetSize,
ViewCustomization(quickSwitch, drawer, monthStyle) ) { quickSwitch, drawer, monthStyle, widgetSize ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
}, },
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -184,6 +188,7 @@ class SettingsViewModel @Inject constructor(
quickSwitchConfig = misc.viewCustomization.quickSwitch, quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder, drawerViewOrder = misc.viewCustomization.drawerOrder,
monthViewStyle = misc.viewCustomization.monthViewStyle, monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize,
allowColorOnUnsupportedCalendars = defaults.allowColor, allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder, defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder, defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -298,6 +303,7 @@ class SettingsViewModel @Inject constructor(
val quickSwitch: QuickSwitchConfig, val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>, val drawerOrder: List<CalendarView>,
val monthViewStyle: MonthViewStyle, val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize,
) )
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ /** 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) } 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) { fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
prefs.setAgendaShowToday(enabled) prefs.setAgendaShowToday(enabled)

View File

@@ -74,6 +74,8 @@ sealed interface AgendaWidgetData {
* from Glance state before an instance has its own state set. * from Glance state before an instance has its own state set.
*/ */
val savedShowToday: Boolean, 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. */ /** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant, val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
@@ -145,6 +147,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
savedRange = savedRange, savedRange = savedRange,
savedPastDisplay = savedPastDisplay, savedPastDisplay = savedPastDisplay,
savedShowToday = showToday, savedShowToday = showToday,
savedWidgetSize = prefs.widgetSize.first(),
now = Clock.System.now(), 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,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 }

View File

@@ -4,12 +4,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 * 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 * size step — a wider stripe or gap would eat title width, which is the thing the
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and * 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 * the "nothing left today" line can never drift out of alignment with the event
* title column the way a hardcoded 19dp could. * 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 internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
/** /**
* The width band a *default* agenda placement can land in, per * Every size the agenda widget varies by [WidgetSize]. Values that genuinely
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`, * shouldn't grow (the horizontal row constants above, corner radii) stay
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but * constants rather than routing through here.
* launcher cell grids vary, so the whole band — not one measured point — has to
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
* to hold. A test pins that.
*
* If the provider's `targetCellWidth` ever changes, this band and the first
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
* revisited together.
*/
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
/**
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
* grow (the horizontal row constants above, corner radii) stay constants rather
* than routing through here.
*/ */
internal data class AgendaMetrics( internal data class AgendaMetrics(
val title: TextUnit, // header "Upcoming" 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 — * 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 * 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, * 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 = fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
@@ -72,18 +58,18 @@ internal data class AgendaMetrics(
* *
* Two documented deviations: * Two documented deviations:
* *
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately — * ‡ SMALL's 13sp day header is off-scale. It is held there deliberately —
* COMPACT reproduces the widget's original constants verbatim so a * SMALL reproduces the widget's original constants verbatim so a widget whose
* default-sized widget looks exactly as it did (#51), and snapping it to * owner never touches the size setting looks exactly as it did (#51), and
* Title Small (14sp) would break that promise for a 1sp gain. * 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, * † 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 * which is far too coarse for four size steps. Where a role would force a
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and * ≥1.4x step between adjacent steps we hold an interpolated value instead and
* mark it. The endpoints stay on real roles. * mark it. The endpoints stay on real roles.
*/ */
private val COMPACT_METRICS = AgendaMetrics( private val SMALL_METRICS = AgendaMetrics(
title = 16.sp, // M3 Title Medium title = 16.sp, // M3 Title Medium
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
eventTitle = 14.sp, // M3 Body Medium eventTitle = 14.sp, // M3 Body Medium
@@ -97,7 +83,7 @@ private val COMPACT_METRICS = AgendaMetrics(
dayHeaderTopPad = 10.dp, dayHeaderTopPad = 10.dp,
) )
private val REGULAR_METRICS = AgendaMetrics( private val MEDIUM_METRICS = AgendaMetrics(
title = 18.sp, // † title = 18.sp, // †
dayHeader = 14.sp, // M3 Title Small dayHeader = 14.sp, // M3 Title Small
eventTitle = 16.sp, // M3 Body Large eventTitle = 16.sp, // M3 Body Large
@@ -125,7 +111,7 @@ private val LARGE_METRICS = AgendaMetrics(
dayHeaderTopPad = 12.dp, dayHeaderTopPad = 12.dp,
) )
private val XLARGE_METRICS = AgendaMetrics( private val EXTRA_LARGE_METRICS = AgendaMetrics(
title = 22.sp, // M3 Title Large title = 22.sp, // M3 Title Large
dayHeader = 18.sp, // † dayHeader = 18.sp, // †
eventTitle = 20.sp, // † eventTitle = 20.sp, // †
@@ -139,12 +125,12 @@ private val XLARGE_METRICS = AgendaMetrics(
dayHeaderTopPad = 14.dp, 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( private val AGENDA_METRICS = listOf(
COMPACT_METRICS, SMALL_METRICS,
REGULAR_METRICS, MEDIUM_METRICS,
LARGE_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.GlanceTheme
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget 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.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme 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.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today 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") 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() { class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
// Exact so the composition sees the widget's live size and can scale type/rows // Single: type and row metrics come from the user's chosen WidgetSize, not
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to // from the widget's measured size (#103, #51), so there is nothing to gain
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the // from Glance building one RemoteViews per host size bucket — and plenty to
// same. // lose, since that roughly doubles the serialized payload. The row list is
// // still capped below: an uncapped agenda (the range goes up to
// Note Exact still asks Glance for one RemoteViews per host size (typically // AgendaRange.MAX_CUSTOM_DAYS = 365) could push the RemoteViews past the
// portrait + landscape) where the old SizeMode.Single produced exactly one — // binder transaction limit and the host would just show "Problem loading
// so the serialized payload roughly doubles. That is why the row list below is // widget".
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS override val sizeMode = SizeMode.Single
// = 365) could otherwise push the RemoteViews past the binder transaction
// limit and the host would just show "Problem loading widget".
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData() val data = context.loadAgendaWidgetData()
@@ -160,12 +164,18 @@ private sealed interface AgendaRow {
@Composable @Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a // Type and row metrics come from the user's chosen size step, read reactively
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51). // 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 // 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. // 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 fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale) val metrics = metricsFor(size).scaledForFont(fontScale)
Column( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()

View File

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

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
@@ -13,7 +14,6 @@ import androidx.glance.GlanceTheme
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalContext import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable 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.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
@@ -73,11 +74,13 @@ import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index") 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 * Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
private val LANE_HEIGHT = 14.dp * in the composition so changing the setting reflects on a live widget by plain
private val DAY_NUMBER_HEIGHT = 18.dp * recomposition — `updateAll` does not reliably re-run the `provideGlance`
private val GRID_HPADDING = 8.dp * 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 { private fun currentMonthIndex(zone: TimeZone): Int {
val t = today(zone) 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 * event bars and titled single-day pills (the in-app lane layout via
* [layoutMonthWeeks]), and prev/next/today navigation. * [layoutMonthWeeks]), and prev/next/today navigation.
* *
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a * Columns are a fixed [MonthMetrics.columnWidth] wide — the user's chosen
* multi-day span renders as a single Box spanning its columns — connected, no * [WidgetSize], never a measured size — and the grid is centred in whatever space
* inter-cell seam, with rounded end caps. The displayed month lives in Glance * the host gives it. Sizing the columns off the launcher-reported width is what
* state and is read reactively in the composition ([currentState]) so the arrows * broke the grid (#103): the reported width overshot the width actually drawn
* move it via plain recomposition, not a (here-unreliable) widget session reload. * 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() { class MonthWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition 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) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val source = context.loadMonthWidgetSource() val source = context.loadMonthWidgetSource()
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// Read fresh (not through the cached source) so toggling the softener // Read fresh (not through the cached source) so toggling the softener or
// redraws with the new choice; it's one cheap DataStore read. // the size redraws with the new choice; two cheap DataStore reads.
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first() val prefs = context.widgetEntryPoint().settingsPrefs()
val soften = prefs.softenCalendarColors.first()
val savedSize = prefs.widgetSize.first()
provideContent { provideContent {
CalendulaGlanceTheme { 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 @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( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.background(GlanceTheme.colors.surface) .background(GlanceTheme.colors.surface)
.padding(horizontal = GRID_HPADDING, vertical = 6.dp), .padding(horizontal = metrics.gridPadding, vertical = 6.dp),
) { ) {
when (source) { when (source) {
MonthWidgetSource.NeedsPermission -> { MonthWidgetSource.NeedsPermission -> {
MonthHeader(label = "Calendula") MonthHeader(label = "Calendula", metrics = metrics)
PermissionMessage() PermissionMessage()
} }
is MonthWidgetSource.Ready -> { is MonthWidgetSource.Ready -> {
val zone = systemZone() val zone = systemZone()
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index) 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) 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)) Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = source.weekStart, colW = colW) WeekdayHeader(weekStart = source.weekStart, metrics = metrics)
weeks.forEach { week -> weeks.forEach { week ->
WeekRow( WeekRow(
week = week, week = week,
@@ -173,7 +200,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
today = source.today, today = source.today,
dark = dark, dark = dark,
soften = soften, soften = soften,
colW = colW, metrics = metrics,
modifier = GlanceModifier.defaultWeight(), 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 @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 val context = LocalContext.current
Row( Row(
modifier = GlanceModifier.fillMaxWidth(), modifier = GlanceModifier.fillMaxWidth(),
@@ -192,6 +237,7 @@ private fun MonthHeader(label: String) {
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_chevron_left, resId = R.drawable.ic_widget_chevron_left,
contentDescription = context.getString(R.string.widget_prev_month), contentDescription = context.getString(R.string.widget_prev_month),
metrics = metrics,
onClick = GlanceModifier.clickable( onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>( actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to -1), actionParametersOf(ShiftMonthAction.deltaKey to -1),
@@ -204,7 +250,7 @@ private fun MonthHeader(label: String) {
text = label, text = label,
style = TextStyle( style = TextStyle(
color = GlanceTheme.colors.primary, color = GlanceTheme.colors.primary,
fontSize = 15.sp, fontSize = metrics.headerTitle,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
), ),
@@ -220,11 +266,13 @@ private fun MonthHeader(label: String) {
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_today, resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today), contentDescription = context.getString(R.string.widget_today),
metrics = metrics,
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()), onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
) )
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_chevron_right, resId = R.drawable.ic_widget_chevron_right,
contentDescription = context.getString(R.string.widget_next_month), contentDescription = context.getString(R.string.widget_next_month),
metrics = metrics,
onClick = GlanceModifier.clickable( onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>( actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to 1), actionParametersOf(ShiftMonthAction.deltaKey to 1),
@@ -235,32 +283,37 @@ private fun MonthHeader(label: String) {
} }
@Composable @Composable
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) { private fun HeaderIcon(
resId: Int,
contentDescription: String,
metrics: MonthMetrics,
onClick: GlanceModifier,
) {
Box( Box(
modifier = GlanceModifier.size(40.dp).then(onClick), modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Image(
provider = ImageProvider(resId), provider = ImageProvider(resId),
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(20.dp), modifier = GlanceModifier.size(metrics.iconImage),
) )
} }
} }
@Composable @Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { private fun WeekdayHeader(weekStart: DayOfWeek, metrics: MonthMetrics) {
Row(modifier = GlanceModifier.fillMaxWidth()) { GridRow {
weekdayNarrowNames(weekStart).forEach { name -> weekdayNarrowNames(weekStart).forEach { name ->
Text( Text(
text = name, text = name,
style = TextStyle( style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant, color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 11.sp, fontSize = metrics.weekday,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
), ),
modifier = GlanceModifier.width(colW), modifier = GlanceModifier.width(metrics.columnWidth),
) )
} }
} }
@@ -283,29 +336,30 @@ private fun WeekRow(
today: LocalDate, today: LocalDate,
dark: Boolean, dark: Boolean,
soften: Boolean, soften: Boolean,
colW: Dp, metrics: MonthMetrics,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
// Day numbers. // Day numbers.
Row(modifier = GlanceModifier.fillMaxWidth()) { GridRow {
week.days.forEach { date -> week.days.forEach { date ->
DayNumber( DayNumber(
date = date, date = date,
isToday = date == today, isToday = date == today,
inMonth = date.month == currentMonth, inMonth = date.month == currentMonth,
colW = colW, metrics = metrics,
) )
} }
} }
Spacer(GlanceModifier.height(2.dp)) Spacer(GlanceModifier.height(2.dp))
// One lane row per event row. A multi-day span is a single Box spanning // 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 -> 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)) 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)) actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
@Composable @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 context = LocalContext.current
val diameter = metrics.dayNumberHeight
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(colW) .width(metrics.columnWidth)
.height(DAY_NUMBER_HEIGHT) .height(diameter)
.clickable(openDayAction(context, date)), .clickable(openDayAction(context, date)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT) .size(diameter)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier), .then(if (isToday) GlanceModifier.cornerRadius(diameter / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -342,7 +397,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
inMonth -> GlanceTheme.colors.onSurface inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.onSurfaceVariant else -> GlanceTheme.colors.onSurfaceVariant
}, },
fontSize = 11.sp, fontSize = metrics.dayNumber,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
), ),
) )
@@ -351,27 +406,39 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
} }
@Composable @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 val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { GridRow {
var col = 0 var col = 0
while (col < 7) { while (col < 7) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
if (span != null) { if (span != null) {
val cols = span.endCol - col + 1 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 col = span.endCol + 1
} else { } else {
val timed = timedEventAt(week, lane, col, week.days[col]) val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) { 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 { } else {
// Empty lane cell: a tap opens that day, so blank space in a // 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. // day column is a day-open target just like the number is.
Box( Box(
GlanceModifier GlanceModifier
.width(colW) .width(metrics.columnWidth)
.height(LANE_HEIGHT) .height(metrics.laneHeight)
.clickable(openDayAction(context, week.days[col])), .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. */ /** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable @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 context = LocalContext.current
val fill = eventFill(event.color, dark, soften) val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(width) .width(width)
.height(LANE_HEIGHT) .height(metrics.laneHeight)
.padding(horizontal = 1.dp) .padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view. // Tap an event bar to open its detail, rooted in the month view.
.clickable( .clickable(
@@ -414,7 +487,7 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
Text( Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) }, text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1, 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), modifier = GlanceModifier.padding(horizontal = 3.dp),
) )
} }
@@ -422,9 +495,9 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
} }
@Composable @Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) { private fun OverflowRow(week: MonthWeek, metrics: MonthMetrics) {
val context = LocalContext.current val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { GridRow {
week.days.forEachIndexed { col, date -> week.days.forEachIndexed { col, date ->
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) 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. // it shows "+N" or is blank) opens that day, same as the app.
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(colW) .width(metrics.columnWidth)
.height(LANE_HEIGHT) .height(metrics.laneHeight)
.clickable(openDayAction(context, date)), .clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
@@ -443,7 +516,7 @@ private fun OverflowRow(week: MonthWeek, colW: Dp) {
Text( Text(
text = "+$hidden", text = "+$hidden",
maxLines = 1, 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), modifier = GlanceModifier.padding(start = 3.dp),
) )
} }

View File

@@ -354,6 +354,14 @@
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string> <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">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_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_widgets_header">Widgets</string>
<string name="settings_widget_size">Widget size</string>
<string name="settings_widget_size_hint">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.</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">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_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> <string name="settings_agenda_range_bar">Range bar</string>

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- minResizeWidth is the floor the smallest widget size step still fits its
seven day columns in (#103); MonthScaleTest pins it against SMALL.gridWidth. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp" android:minWidth="250dp"
android:minHeight="180dp" android:minHeight="180dp"
android:targetCellWidth="4" android:targetCellWidth="4"
android:targetCellHeight="4" android:targetCellHeight="4"
android:minResizeWidth="180dp" android:minResizeWidth="250dp"
android:minResizeHeight="150dp" android:minResizeHeight="150dp"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" android:widgetCategory="home_screen"

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 package de.jeanlucmakiola.calendula.widget.agenda
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.widget.WidgetScale import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.scaleFor
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AgendaScaleTest { class AgendaScaleTest {
@@ -13,23 +11,10 @@ class AgendaScaleTest {
// --- the "default size is unchanged" regression guard --------------------- // --- the "default size is unchanged" regression guard ---------------------
@Test @Test
fun `every default placement width stays COMPACT`() { fun `SMALL metrics equal the widget's original constants`() {
// Ties the guarantee to the provider XML's declared 3-cell default rather // SMALL is the default, so if this fails an agenda widget whose owner never
// than to one measured launcher: the whole band a default placement can // touched the size setting no longer looks as it did (#51).
// land in must bucket to COMPACT, or a freshly placed widget silently val m = metricsFor(WidgetSize.SMALL)
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
val band = AGENDA_DEFAULT_WIDTH_BAND
var w = band.start
while (w <= band.endInclusive) {
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
w += 1.dp
}
}
@Test
fun `COMPACT metrics equal the widget's original constants`() {
// If this fails, a default-sized agenda widget no longer looks as it did.
val m = metricsFor(WidgetScale.COMPACT)
assertThat(m.title).isEqualTo(16.sp) assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp) assertThat(m.eventTitle).isEqualTo(14.sp)
@@ -54,10 +39,10 @@ class AgendaScaleTest {
// --- the ramp ------------------------------------------------------------ // --- the ramp ------------------------------------------------------------
@Test @Test
fun `sizes are non-decreasing across the tiers`() { fun `sizes are non-decreasing across the steps`() {
val tiers = WidgetScale.entries.map(::metricsFor) val steps = WidgetSize.entries.map(::metricsFor)
tiers.zipWithNext { small, big -> steps.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value) assertThat(big.title.value).isAtLeast(small.title.value)
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value) assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.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`() { 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 // The secondary line steps more slowly on purpose; if it ever caught up the
// row would lose its hierarchy. // 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) assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
} }
} }
@Test @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". // Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetScale.COMPACT) val base = metricsFor(WidgetSize.SMALL)
val top = metricsFor(WidgetScale.XLARGE) val top = metricsFor(WidgetSize.EXTRA_LARGE)
assertThat(top.title.value / base.title.value).isLessThan(1.5f) assertThat(top.title.value / base.title.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.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`() { fun `the stripe tracks the system font scale`() {
// The stripe is Dp, the text beside it is sp: without this the two diverge // 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. // 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(1f).stripeH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f) 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) assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
@@ -104,7 +89,7 @@ class AgendaScaleTest {
@Test @Test
fun `scaling for the default font scale changes nothing`() { 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) assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
} }
@@ -112,7 +97,7 @@ class AgendaScaleTest {
fun `font scaling leaves the sp sizes alone`() { fun `font scaling leaves the sp sizes alone`() {
// Glance already applies the font scale to sp; scaling them here too would // Glance already applies the font scale to sp; scaling them here too would
// double-count it. // double-count it.
val m = metricsFor(WidgetScale.REGULAR) val m = metricsFor(WidgetSize.MEDIUM)
val scaled = m.scaledForFont(1.3f) val scaled = m.scaledForFont(1.3f)
assertThat(scaled.title).isEqualTo(m.title) assertThat(scaled.title).isEqualTo(m.title)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle) assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)

View File

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