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:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -28,6 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- The calendar picker in the event form and in the .ics import screen now ends
|
- 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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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 }
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user