Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/104
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user