Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/104
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- The calendar picker in the event form and in the .ics import screen now ends
|
- 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]).
|
||||||
|
- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda
|
||||||
|
widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||||
|
the widget used to make from its own measurements. Small is what it looks like
|
||||||
|
today, so nothing changes until you turn it up ([#51]).
|
||||||
|
|
||||||
### Changed
|
### 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,11 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
unaffected.
|
unaffected.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- A month-grid widget stays a month grid. Since 2.16.0 a placed month widget
|
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||||
could redraw itself as the agenda widget a little after any change to your
|
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||||
events, because the release build merged the two widgets into a single class
|
after any change to your events, and could draw only about four day columns
|
||||||
and Android could no longer tell which of them a widget on your home screen
|
with the last one cut off part-way through. Both came from the release build
|
||||||
was ([#89]).
|
merging the two widgets into a single class, so Android could no longer tell
|
||||||
|
which of them a widget on your home screen was — and the month grid was handed
|
||||||
|
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||||
- The back gesture on **Settings → Views** returns to Settings instead of
|
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||||
leaving Settings altogether and dropping you on the calendar. Special dates
|
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||||
did the same ([#81]).
|
did the same ([#81]).
|
||||||
@@ -1216,3 +1222,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
|
|||||||
@@ -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 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
|
* 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")
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -684,6 +687,12 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_widget_size),
|
||||||
|
summary = widgetSizeLabel(state.widgetSize),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showWidgetSize = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range_bar),
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
@@ -810,6 +819,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),
|
||||||
@@ -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
|
@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)
|
||||||
|
|||||||
@@ -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 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). */
|
/** 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). */
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ 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.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -162,8 +164,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)
|
||||||
@@ -186,6 +189,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,
|
||||||
@@ -300,6 +304,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. */
|
||||||
@@ -478,6 +483,28 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
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) {
|
fun setAgendaShowToday(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setAgendaShowToday(enabled)
|
prefs.setAgendaShowToday(enabled)
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -354,6 +354,13 @@
|
|||||||
<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_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">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>
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user