From 04a677dc1012dbd5e5a0c38fcb369f1a8d1b7cfe Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 11:12:18 +0200 Subject: [PATCH] Per-widget config screen: background, theme and agenda options (#292) --- app/src/main/AndroidManifest.xml | 10 + .../calendula/data/prefs/SettingsPrefs.kt | 17 +- .../calendula/ui/settings/SettingsScreen.kt | 2 +- .../calendula/ui/settings/SettingsUiState.kt | 7 +- .../ui/settings/SettingsViewModel.kt | 8 - .../calendula/ui/settings/WidgetSettings.kt | 134 ++++--- .../calendula/widget/WidgetConfig.kt | 88 +++++ .../calendula/widget/WidgetData.kt | 77 ++-- .../calendula/widget/WidgetRedraw.kt | 31 +- .../calendula/widget/WidgetTheme.kt | 17 +- .../calendula/widget/agenda/AgendaWidget.kt | 134 +++++-- .../widget/agenda/AgendaWidgetReceiver.kt | 6 + .../calendula/widget/config/PlacedWidgets.kt | 31 ++ .../widget/config/WidgetConfigActivity.kt | 130 +++++++ .../widget/config/WidgetConfigScreen.kt | 328 ++++++++++++++++++ .../widget/config/WidgetConfigViewModel.kt | 242 +++++++++++++ .../calendula/widget/month/MonthGrid.kt | 9 +- .../calendula/widget/month/MonthWidget.kt | 65 ++-- .../widget/month/MonthWidgetReceiver.kt | 6 + app/src/main/res/values/strings.xml | 24 +- app/src/main/res/values/themes.xml | 6 + .../main/res/xml/appwidget_info_agenda.xml | 2 + app/src/main/res/xml/appwidget_info_month.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 5 +- floret-kit | 2 +- 25 files changed, 1191 insertions(+), 192 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7dc63c5..44143a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -318,6 +318,16 @@ android:exported="false" /> + + + + + = prefs.flow(SettingsKeys.dragToReschedule) val tapOpensEditor: Flow = prefs.flow(SettingsKeys.tapOpensEditor) val agendaScreenRange: Flow = prefs.flow(SettingsKeys.agendaScreenRange) - val agendaWidgetRange: Flow = prefs.flow(SettingsKeys.agendaWidgetRange) - val widgetSize: Flow = prefs.flow(SettingsKeys.widgetSize) val agendaShowRangeBar: Flow = prefs.flow(SettingsKeys.agendaShowRangeBar) val agendaShowToday: Flow = prefs.flow(SettingsKeys.agendaShowToday) val defaultView: Flow = prefs.flow(SettingsKeys.defaultView) @@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor( suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled) suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled) suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range) - suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range) - suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size) suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled) suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled) suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view) @@ -497,13 +493,16 @@ object SettingsKeys { /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: Pref = agendaRangePref("agenda_screen_range") - /** How far ahead the agenda **widget** shows events (v2.11). */ + /** + * How far ahead the agenda **widget** shows events (v2.11). Read-only since + * widgets keep their own options (#292): the default for a widget that has + * none yet. + */ val agendaWidgetRange: Pref = agendaRangePref("agenda_widget_range") /** * The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL] - * reproduces its original metrics. The month widget takes no size setting — - * it divides the width it is given by seven (#103). + * reproduces its original metrics. Read-only like [agendaWidgetRange] (#292). */ val widgetSize: Pref = enumPref("widget_size", WidgetSize.SMALL) @@ -511,8 +510,8 @@ object SettingsKeys { val agendaShowRangeBar: Pref = booleanPref("agenda_show_range_bar", true) /** - * Whether the agenda (screen and widget) always anchors today at the top with - * a "nothing left today" placeholder (issue #35). + * Whether the agenda screen always anchors today at the top with a "nothing + * left today" placeholder (issue #35), and the default for a widget (#292). */ val agendaShowToday: Pref = booleanPref("agenda_show_today", true) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 756dbe0..2ffe433 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -155,7 +155,7 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets) + WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets) } AnimatedVisibility( visible = section == SettingsSection.Licences, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 5d4dcf1..53c14bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle -import de.jeanlucmakiola.calendula.widget.WidgetSize /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -51,14 +50,10 @@ data class SettingsUiState( val tapOpensEditor: Boolean = false, /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, - /** How far ahead the agenda widget shows events (v2.11). */ - val agendaWidgetRange: AgendaRange = AgendaRange.Month, - /** Whether the agenda (screen + widget) always anchors today at the top (#35). */ + /** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */ val agendaShowToday: Boolean = true, /** Whether the agenda shows its top range bar — header + switcher (v2.11). */ val agendaShowRangeBar: Boolean = true, - /** The size step the agenda widget draws its text at (#51). */ - val widgetSize: WidgetSize = WidgetSize.SMALL, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 1d4b93a..8326032 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -45,7 +45,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN -import de.jeanlucmakiola.calendula.widget.WidgetSize import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -106,10 +105,8 @@ class SettingsViewModel @Inject constructor( dragToReschedule = this[SettingsKeys.dragToReschedule], tapOpensEditor = this[SettingsKeys.tapOpensEditor], agendaScreenRange = this[SettingsKeys.agendaScreenRange], - agendaWidgetRange = this[SettingsKeys.agendaWidgetRange], agendaShowToday = this[SettingsKeys.agendaShowToday], agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar], - widgetSize = this[SettingsKeys.widgetSize], defaultView = this[SettingsKeys.defaultView], quickSwitchConfig = this[SettingsKeys.quickSwitchConfig], monthViewStyle = this[SettingsKeys.monthViewStyle], @@ -283,13 +280,8 @@ class SettingsViewModel @Inject constructor( fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) } - fun setAgendaWidgetRange(range: AgendaRange) = save { setAgendaWidgetRange(range) } - fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) } - /** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */ - fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) } - fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) } fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 456efbe..aad1d35 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -18,56 +20,47 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.qs.NewEventTileService -import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker -import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel -import de.jeanlucmakiola.calendula.widget.WidgetSize -import de.jeanlucmakiola.calendula.widget.agenda.metricsFor import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.floret.locale.currentLocale -import kotlin.math.roundToInt +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.calendula.widget.config.PlacedWidget +import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity +import de.jeanlucmakiola.calendula.widget.config.WidgetKind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import de.jeanlucmakiola.calendula.widget.config.backgroundLabel +import de.jeanlucmakiola.calendula.widget.config.label +import de.jeanlucmakiola.calendula.widget.config.placedWidgets +import de.jeanlucmakiola.calendula.widget.config.themeLabel /** - * Widgets & tiles (#69): the home-screen widgets' settings and the Quick - * Settings tile shortcut. The agenda widget keeps its own range and text size, - * separate from the Agenda screen's range in [ViewsScreen]. + * Widgets & tiles (#69): the placed home-screen widgets, each opening its own + * config screen (#292), and the Quick Settings tile shortcut. */ @Composable internal fun WidgetsScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, onBack: () -> Unit, visible: Boolean = true, ) { - var showAgendaWidgetRange by remember { mutableStateOf(false) } - var showWidgetSize by remember { mutableStateOf(false) } - CollapsingScaffold( title = stringResource(R.string.settings_section_widgets), onBack = onBack, predictiveBack = true, visible = visible, ) { - GroupedHint(stringResource(R.string.settings_widgets_hint)) - Spacer(Modifier.height(8.dp)) - - GroupedRow( - title = stringResource(R.string.settings_agenda_widget_range), - summary = agendaRangeLabel(state.agendaWidgetRange), - position = Position.Top, - onClick = { showAgendaWidgetRange = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_widget_size), - summary = widgetSizeLabel(state.widgetSize), - position = Position.Bottom, - onClick = { showWidgetSize = true }, + GroupedHint( + stringResource( + // The launcher's "Edit widget" action only exists from Android 12. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint + else R.string.settings_widgets_hint_legacy, + ), ) + GroupedSectionHeader(stringResource(R.string.settings_widgets_placed)) + PlacedWidgetRows() // The add-tile prompt is API 33+; below that the tile is still // addable from the QS editor, so the row just doesn't appear. @@ -76,30 +69,44 @@ internal fun WidgetsScreen( QuickSettingsTileRow() } } +} - if (showAgendaWidgetRange) { - AgendaRangePicker( - title = stringResource(R.string.settings_agenda_widget_range), - description = stringResource(R.string.settings_agenda_widget_range_hint), - selected = state.agendaWidgetRange, - weekStart = state.weekStart.resolveFirstDay(currentLocale()), - onSelect = viewModel::setAgendaWidgetRange, - onDismiss = { showAgendaWidgetRange = false }, - ) +@Composable +private fun PlacedWidgetRows() { + val context = LocalContext.current + // Read on every resume, the first included, so a widget added, removed or + // edited meanwhile shows up. + var refresh by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + refresh++ + onPauseOrDispose {} } - if (showWidgetSize) { - OptionPicker( - title = stringResource(R.string.settings_widget_size), - header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, - predictiveBack = true, - options = WidgetSize.entries, - selected = state.widgetSize, - label = { widgetSizeLabel(it) }, - // No live preview: the widget is Glance/RemoteViews, so a Compose - // mock-up would be a second implementation free to drift. - summary = { widgetSizeSummary(it) }, - onSelect = viewModel::setWidgetSize, - onDismiss = { showWidgetSize = false }, + val widgets by produceState?>(initialValue = null, refresh) { + if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() } + } + val placed = widgets ?: return + if (placed.isEmpty()) { + GroupedRow( + title = stringResource(R.string.settings_widgets_none), + position = Position.Alone, + dimmed = true, + ) + return + } + val kindCounts = placed.groupingBy { it.kind }.eachCount() + val kindSeen = mutableMapOf() + placed.forEachIndexed { index, widget -> + val number = kindSeen.merge(widget.kind, 1, Int::plus)!! + val label = stringResource(widget.kind.label) + GroupedRow( + title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label, + summary = stringResource( + R.string.widget_appearance_summary, + backgroundLabel(widget.appearance.background), + themeLabel(widget.appearance.theme), + ), + position = positionOf(index, placed.size), + onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) }, ) } } @@ -131,24 +138,3 @@ private fun requestAddQsTile(context: Context) { context.mainExecutor, ) { /* result code unused — the system surfaces its own feedback */ } } - -/** - * What a size step does, as a percentage of the smallest one's event text. - * Derived from the widget's own [metricsFor] table rather than hardcoded. - */ -@Composable -private fun widgetSizeSummary(size: WidgetSize): String { - val base = metricsFor(WidgetSize.SMALL).eventTitle.value - val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt() - return stringResource(R.string.settings_widget_size_summary, percent) -} - -@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 - }, -) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt new file mode 100644 index 0000000..69ac74d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.storageValue +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.toEnum + +/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */ +enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK } + +/** How a widget looks, set per placed widget in its config screen (#292). */ +data class WidgetAppearance( + val background: WidgetBackground = WidgetBackground.TINTED, + val theme: WidgetTheme = WidgetTheme.FOLLOW_APP, +) + +/** What the agenda widget lists, per placed widget. */ +data class AgendaWidgetOptions( + val range: AgendaRange, + val textSize: WidgetSize, + val showToday: Boolean, + val pastDisplay: PastEventDisplay, +) + +internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) { + WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT + WidgetTheme.DARK -> WidgetThemeMode.DARK + WidgetTheme.FOLLOW_APP -> when (app) { + ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM + ThemeMode.LIGHT -> WidgetThemeMode.LIGHT + ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK + } +} + +private val BackgroundKey = stringPreferencesKey("cfg_background") +private val ThemeKey = stringPreferencesKey("cfg_theme") +private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range") +private val TextSizeKey = stringPreferencesKey("cfg_text_size") +private val ShowTodayKey = booleanPreferencesKey("cfg_show_today") +private val PastDisplayKey = stringPreferencesKey("cfg_past_display") + +internal val Preferences.widgetAppearance: WidgetAppearance + get() = WidgetAppearance( + background = this[BackgroundKey].toEnum(WidgetBackground.TINTED), + theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP), + ) + +/** + * This widget's agenda options. A widget placed before per-widget options + * existed has none stored, and keeps following the app-wide values in + * [defaults] until it is configured. + */ +internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions( + range = parseAgendaRange(this[AgendaRangeKey], defaults.range), + textSize = this[TextSizeKey].toEnum(defaults.textSize), + showToday = this[ShowTodayKey] ?: defaults.showToday, + pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay), +) + +/** Whether this widget has agenda options of its own, rather than following the app's. */ +internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null + +internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) { + this[BackgroundKey] = appearance.background.name + this[ThemeKey] = appearance.theme.name +} + +internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { + this[AgendaRangeKey] = options.range.storageValue() + this[TextSizeKey] = options.textSize.name + this[ShowTodayKey] = options.showToday + this[PastDisplayKey] = options.pastDisplay.name +} + +internal suspend fun Context.widgetState(id: GlanceId): Preferences = + getAppWidgetState(this, PreferencesGlanceStateDefinition, id) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 5b5f4a9..dca5a54 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -18,6 +18,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.get import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -46,26 +47,46 @@ import kotlin.time.Instant */ internal const val MONTH_WIDGET_PAD_MONTHS = 3 -/** Every setting the widgets draw with: what the loads read, and what [WidgetRefreshCoordinator] watches. */ +/** + * Every app setting the widgets draw with: what the loads read, and what + * [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults + * for a widget that has no options of its own yet. + */ internal data class WidgetSettings( val soften: Boolean, val weekStart: WeekStartPref, val timeFormat: TimeFormatPref, + val themeMode: ThemeMode, val pastDisplay: PastEventDisplay, val agendaRange: AgendaRange, val widgetSize: WidgetSize, val showToday: Boolean, ) { - /** The part the month widget draws with; the rest is agenda-only. */ - val month: Pair get() = soften to weekStart + /** What every widget draws with; the agenda defaults aside. */ + val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode) + + val agendaDefaults: AgendaWidgetOptions + get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay) } +/** The [WidgetSettings] a configured widget still takes from the app. */ +internal data class SharedWidgetSettings( + val soften: Boolean, + val weekStart: WeekStartPref, + val timeFormat: TimeFormatPref, + val themeMode: ThemeMode, +) + +/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */ +internal data class Themed(val content: T, val appTheme: ThemeMode) + internal val SettingsPrefs.widgetSettings: Flow get() = data.map { WidgetSettings( soften = it[SettingsKeys.softenCalendarColors], weekStart = it[SettingsKeys.weekStart], timeFormat = it[SettingsKeys.timeFormat], + themeMode = it[SettingsKeys.themeMode], pastDisplay = it[SettingsKeys.pastEventDisplay], agendaRange = it[SettingsKeys.agendaWidgetRange], widgetSize = it[SettingsKeys.widgetSize], @@ -97,7 +118,7 @@ sealed interface AgendaWidgetData { val pastDisplay: PastEventDisplay, /** "Always show today" (#35): anchor today at the top even with no events left. */ val showToday: Boolean, - val widgetSize: WidgetSize, + val textSize: WidgetSize, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -121,19 +142,28 @@ sealed interface MonthWidgetSource { ) : MonthWidgetSource } +/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */ +internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults) + return Themed(loadAgendaWidgetData(settings, options), settings.themeMode) +} + /** * One-shot read of the upcoming agenda for the widget. Reuses the app's * [agendaRange] window and [groupAgendaDays] grouping, and the repository's * [first]-emitted snapshot already has hidden calendars filtered out. */ -internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { +internal suspend fun Context.loadAgendaWidgetData( + settings: WidgetSettings, + options: AgendaWidgetOptions, +): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val settings = ep.settingsPrefs().widgetSettings.first() val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) - val dayCount = settings.agendaRange.dayCount(anchor, weekStart) + val dayCount = options.range.dayCount(anchor, weekStart) val window = agendaRange(anchor, dayCount - 1, zone) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY) val instances = ep.calendarRepository().instances(window).first() @@ -142,9 +172,9 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { days = groupAgendaDays(anchor, windowEnd, instances, zone), is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), soften = settings.soften, - pastDisplay = settings.pastDisplay, - showToday = settings.showToday, - widgetSize = settings.widgetSize, + pastDisplay = options.pastDisplay, + showToday = options.showToday, + textSize = options.textSize, now = Clock.System.now(), ) } @@ -167,30 +197,35 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + * stale. */ private object MonthSourceCache { - private class Entry(val center: Int, val stamp: Long, val source: MonthWidgetSource.Ready) + private class Entry(val center: Int, val stamp: Long, val source: Themed) private val entries = ConcurrentHashMap() - fun get(id: GlanceId, center: Int, stamp: Long): MonthWidgetSource.Ready? = - entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.today == today(systemZone()) } + fun get(id: GlanceId, center: Int, stamp: Long): Themed? = + entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) } ?.source - fun put(id: GlanceId, center: Int, stamp: Long, source: MonthWidgetSource) { - if (source is MonthWidgetSource.Ready) entries[id] = Entry(center, stamp, source) + fun put(id: GlanceId, center: Int, stamp: Long, source: Themed) { + val ready = source.content as? MonthWidgetSource.Ready ?: return + entries[id] = Entry(center, stamp, Themed(ready, source.appTheme)) } } -/** [loadMonthWidgetSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ -internal suspend fun Context.loadMonthWidgetSource(id: GlanceId, centerIndex: Int, stamp: Long): MonthWidgetSource = +/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ +internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed = MonthSourceCache.get(id, centerIndex, stamp) - ?: loadMonthWidgetSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } + ?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } -/** Read backing the month widget's grid for the window around [centerIndex]. */ -private suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource { +/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */ +internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + return Themed(queryMonthSource(centerIndex, settings), settings.themeMode) +} + +private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() val ep = widgetEntryPoint() - val settings = ep.settingsPrefs().widgetSettings.first() val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS) // Padded by the grid's spill into the neighbouring months. val from = firstDayOfMonthIndex(months.first) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt index 222228d..5425900 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.calendula.widget import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.SystemClock @@ -14,6 +16,8 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver import de.jeanlucmakiola.floret.glance.WidgetRefresher import de.jeanlucmakiola.floret.glance.WidgetRollover import de.jeanlucmakiola.floret.glance.updatePlaced +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import androidx.glance.appwidget.GlanceAppWidgetManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -63,6 +67,18 @@ internal suspend fun redrawWidgets(context: Context) { private suspend fun redrawAgendaWidgets(context: Context) = AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) +/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */ +private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) { + val appContext = context.applicationContext + val ids = AppWidgetManager.getInstance(appContext) + ?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return + val glanceManager = GlanceAppWidgetManager(appContext) + ids.forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId) + } +} + private suspend fun redrawMonthWidgets(context: Context) = MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true) @@ -80,8 +96,9 @@ internal fun isProviderEcho(): Boolean { * Redraws the widgets on the app's own calendar writes, visibility changes and * the settings they draw with, so those don't wait for `PROVIDER_CHANGED`, * which arrives seconds late or not at all (#420, #229). Changes from outside - * the app are [WidgetUpdateReceiver]'s. A setting only the agenda widget draws - * with leaves the month widget alone. Started once from `CalendulaApp`. + * the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw + * agenda widgets that have none of their own (#292). Started once from + * `CalendulaApp`. */ @Singleton class WidgetRefreshCoordinator @Inject constructor( @@ -96,14 +113,20 @@ class WidgetRefreshCoordinator @Inject constructor( fun start() { if (!started.compareAndSet(false, true)) return val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) } + val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) } val month = WidgetRefresher(scope) { redrawMonthWidgets(context) } watch(repository.localChanges) { lastLocalChange.set(SystemClock.elapsedRealtime()) agenda.request() month.request() } - watch(prefs.widgetSettings.drop(1)) { agenda.request() } - watch(prefs.widgetSettings.map { it.month }.distinctUntilChanged().drop(1)) { month.request() } + watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { + agenda.request() + month.request() + } + watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { + unconfiguredAgenda.request() + } } private fun watch(changes: Flow<*>, onChange: () -> Unit) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index 3fba7de..8809be4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -5,23 +5,34 @@ import androidx.glance.text.TextDecoration import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback import de.jeanlucmakiola.floret.glance.FloretGlanceTheme +import de.jeanlucmakiola.floret.glance.WidgetSurface import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode /** * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's - * hand-tuned fallback schemes below that, following the system's light/dark. + * hand-tuned fallback schemes below that, in the light/dark [mode] the widget + * was configured with (#292). */ @Composable -fun CalendulaGlanceTheme(content: @Composable () -> Unit) { +fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) { FloretGlanceTheme( lightScheme = CalendulaLightFallback, darkScheme = CalendulaDarkFallback, dynamicColor = true, - mode = WidgetThemeMode.SYSTEM, + mode = mode, content = content, ) } +/** A widget's root: its configured theme and background around [content]. */ +@Composable +internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) { + CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) { + WidgetSurface(background = appearance.background, content = content) + } +} + /** * Glance's counterpart to the app's `declinedDecoration`: a declined invitation * reads the same on the home screen as it does inside the app (#180). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index f027750..1695baa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -16,7 +16,6 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.color.ColorProvider import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity @@ -55,9 +54,21 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.widget.AgendaWidgetData -import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.WidgetSize -import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.loadAgendaWidget +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetGroupedRow +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode +import androidx.datastore.preferences.core.Preferences +import androidx.glance.currentState import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp import de.jeanlucmakiola.floret.glance.refreshStamp @@ -89,18 +100,40 @@ class AgendaWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val stamp = refreshStamp(context, id) - val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp) + val widgetData = WidgetData(context.loadAgendaWidget(id), stamp) provideContent { // A running session never re-runs this preamble, so every redraw // moves the stamp and the data reloads here instead (#420). - val data = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidgetData() } - CalendulaGlanceTheme { - AgendaWidgetBody(data = data) - } + val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) } + AgendaWidgetContent( + data = loaded.content, + appearance = currentState().widgetAppearance, + appTheme = loaded.appTheme, + ) } } } +/** + * The whole agenda widget, shared with its config screen's preview (#292). The + * [preview] draws its rows as a plain column, which RemoteViews can show + * outside a widget host on every Android version. + */ +@Composable +internal fun AgendaWidgetContent( + data: AgendaWidgetData, + appearance: WidgetAppearance, + appTheme: ThemeMode, + preview: Boolean = false, +) { + CalendulaWidgetRoot(appearance, appTheme) { + AgendaWidgetBody(data = data, preview = preview) + } +} + +/** Rows a preview draws: more than any widget shows at once. */ +private const val PREVIEW_ROWS = 16 + /** Reloads and redraws the tapped widget (header refresh button). */ class RefreshAgendaAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { @@ -119,30 +152,31 @@ private const val MAX_AGENDA_ROWS = 100 /** Flat row model so the [LazyColumn] can mix day headers and events. */ private sealed interface AgendaRow { - data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow - data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow + val date: LocalDate + + data class Header(override val date: LocalDate, val today: LocalDate) : AgendaRow + data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow /** "Nothing left today" line under an anchored, event-less today (#35). */ - data class Placeholder(val date: LocalDate) : AgendaRow + data class Placeholder(override val date: LocalDate) : AgendaRow } @Composable -private fun AgendaWidgetBody(data: AgendaWidgetData) { +private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { // The stripe is re-resolved against the system font scale so it tracks the // sp-sized text beside it instead of drifting at large accessibility settings. - val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL + val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val metrics = metricsFor(size).scaledForFont(fontScale) Column( modifier = GlanceModifier .fillMaxSize() - .background(GlanceTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 6.dp), ) { AgendaHeader(metrics) Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) - is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics) + is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview) } } } @@ -151,6 +185,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData) { private fun AgendaRows( data: AgendaWidgetData.Ready, metrics: AgendaMetrics, + preview: Boolean, ) { val pastDisplay = data.pastDisplay // Drop finished events (and any day they leave empty) when the mode is @@ -189,23 +224,59 @@ private fun AgendaRows( // stranded with nothing under it. .take(MAX_AGENDA_ROWS) .dropLastWhile { it is AgendaRow.Header } - LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - items(rows.size) { index -> - when (val row = rows[index]) { - is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) - is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) - is AgendaRow.Event -> EventRow( - event = row.event, - day = row.date, - soften = data.soften, - is24Hour = data.is24Hour, - dimmed = pastDisplay == PastEventDisplay.DIM && - row.event.hasEnded(data.now), - metrics = metrics, - ) + // On a transparent widget each day becomes one card of grouped rows, + // so it stays readable over the wallpaper (#292). + val positions = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) dayPositions(rows) else null + val item: @Composable (Int) -> Unit = { index -> + val row = rows[index] + if (positions != null) { + WidgetGroupedRow( + position = positions[index], + modifier = if (row is AgendaRow.Header && index > 0) GlanceModifier.padding(top = 6.dp) else GlanceModifier, + color = GlanceTheme.colors.widgetBackground, + ) { + AgendaRowContent(row, data, metrics) } + } else { + AgendaRowContent(row, data, metrics) } } + if (preview) { + Column(modifier = GlanceModifier.fillMaxSize()) { + repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) } + } + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { + items(rows.size) { item(it) } + } + } + } +} + +/** Where each row sits among the rows of its day, for the grouped-card shapes. */ +private fun dayPositions(rows: List): List = buildList { + var start = 0 + for (end in 1..rows.size) { + if (end == rows.size || rows[end].date != rows[start].date) { + for (i in start until end) add(positionOf(i - start, end - start)) + start = end + } + } +} + +@Composable +private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) { + when (row) { + is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) + is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) + is AgendaRow.Event -> EventRow( + event = row.event, + day = row.date, + soften = data.soften, + is24Hour = data.is24Hour, + dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), + metrics = metrics, + ) } } @@ -213,7 +284,7 @@ private fun AgendaRows( private fun AgendaHeader(metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Row( - modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), + modifier = GlanceModifier.fillMaxWidth().wallpaperBacking().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // Tap the "Upcoming" title to open the app on the user's default view @@ -340,7 +411,7 @@ private fun EventRow( fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it } - val stripeColor = ColorProvider(day = stripe(dark = false), night = stripe(dark = true)) + val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true)) val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface Row( modifier = GlanceModifier @@ -396,6 +467,7 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) { Text( text = context.getString(resId), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt index 2ed8638..ee48a12 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt @@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.syncWidgetRollover import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState /** * Host-facing receiver for the agenda widget. Declared in the manifest with the @@ -31,6 +32,11 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { syncWidgetRollover(context) } + /** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */ + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + /** * Self-heal on the system's own `updatePeriodMillis` wake-up — see * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate]. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt new file mode 100644 index 0000000..4425ac4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetState + +/** One of the app's widgets on the home screen, for the settings list. */ +data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance) + +/** + * Every placed widget, agenda ones first, each in placement order. An id the + * host bound but never laid out (a placement it abandoned without freeing the + * id) has no size yet and is left out, as is one removed while this reads. + */ +suspend fun Context.placedWidgets(): List { + val manager = AppWidgetManager.getInstance(this) ?: return emptyList() + val glanceManager = GlanceAppWidgetManager(this) + return WidgetKind.entries.flatMap { kind -> + manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id -> + if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) { + return@mapNotNull null + } + val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null + PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt new file mode 100644 index 0000000..12cddfb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt @@ -0,0 +1,130 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.FontRole +import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings +import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache +import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.get +import de.jeanlucmakiola.floret.prefs.isDark +import de.jeanlucmakiola.floret.prefs.isPureBlack +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A widget's config screen (#292): opened by the launcher when the widget is + * placed, from its "Edit widget" action on Android 12+, and from the app's + * widget settings. Shows over the wallpaper, so a transparent widget previews + * against the real home screen. + */ +@AndroidEntryPoint +class WidgetConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + private val viewModel: WidgetConfigViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = viewModel.appWidgetId + // Backing out of a fresh placement leaves no unconfigured widget behind. + setResult(RESULT_CANCELED, result(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + enableEdgeToEdge() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + WidgetConfigEvent.SAVED -> { + setResult(RESULT_OK, result(appWidgetId)) + finish() + } + WidgetConfigEvent.SAVE_FAILED -> + Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show() + } + } + } + } + val fontSettings = settingsPrefs.data.map { + AppFontSettings( + brand = it[SettingsKeys.brandFont], + plain = it[SettingsKeys.plainFont], + brandStamp = it[SettingsKeys.brandFontStamp], + plainStamp = it[SettingsKeys.plainFontStamp], + ) + } + // Seeded from MainActivity's mirrored copy, so an app theme override + // shows from the first frame. + val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM + val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true + setContent { + val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme) + val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor) + val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null) + val context = LocalContext.current + // Nothing until the font choice is known, rather than a frame in the default one. + val typography = remember(fonts, context) { + fonts?.let { + calendulaTypography( + brand = resolveFontFamily(it.brand, FontRole.BRAND, context), + plain = resolveFontFamily(it.plain, FontRole.PLAIN, context), + ) + } + } ?: return@setContent + CalendulaTheme( + darkTheme = themeMode.isDark(isSystemInDarkTheme()), + dynamicColor = dynamicColor, + pureBlack = themeMode.isPureBlack, + typography = typography, + ) { + val state by viewModel.state.collectAsStateWithLifecycle() + if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() } + WidgetConfigScreen( + state = state, + onClose = ::finish, + onSave = viewModel::save, + onRetry = viewModel::retry, + onAppearance = viewModel::setAppearance, + onAgendaOptions = viewModel::setAgendaOptions, + ) + } + } + } + + private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + + companion object { + /** Opens placed widget [appWidgetId]'s config screen from inside the app. */ + fun intent(context: Context, appWidgetId: Int): Intent = + Intent(context, WidgetConfigActivity::class.java) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt new file mode 100644 index 0000000..603e29e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -0,0 +1,328 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetSize +import de.jeanlucmakiola.calendula.widget.WidgetTheme +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent +import de.jeanlucmakiola.calendula.widget.agenda.metricsFor +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent +import de.jeanlucmakiola.floret.components.GroupedChoiceRow +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.GroupedSwitchRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview +import de.jeanlucmakiola.floret.glance.WallpaperWindow +import de.jeanlucmakiola.floret.glance.WidgetBackground +import kotlin.math.roundToInt + +private val PreviewMaxHeight = 280.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WidgetConfigScreen( + state: WidgetConfigUiState, + onClose: () -> Unit, + onSave: () -> Unit, + onRetry: () -> Unit, + onAppearance: (WidgetAppearance) -> Unit, + onAgendaOptions: (AgendaWidgetOptions) -> Unit, +) { + val ready = state as? WidgetConfigUiState.Ready + val surface = MaterialTheme.colorScheme.surface + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + Text( + text = ready?.let { stringResource(it.kind.label) }.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close)) + } + }, + actions = { + Button( + onClick = onSave, + enabled = ready != null && !ready.saving, + modifier = Modifier.padding(end = 8.dp), + ) { + Text(stringResource(R.string.dialog_save)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = surface), + ) + when (state) { + WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box( + modifier = Modifier.fillMaxSize().background(surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + WidgetConfigUiState.Failed -> Column( + modifier = Modifier.fillMaxSize().background(surface).padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.widget_config_load_failed), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) } + } + is WidgetConfigUiState.Ready -> { + WallpaperWindow(color = surface) { + val maxSize = DpSize(maxWidth, PreviewMaxHeight) + when (val preview = state.preview) { + null -> Box( + modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2), + contentAlignment = Alignment.Center, + ) { + if (state.previewFailed) PreviewFailed() else CircularProgressIndicator() + } + else -> GlanceWidgetPreview( + size = state.placedSize, + maxSize = maxSize, + preview, + state.appearance, + state.appTheme, + modifier = Modifier.padding(vertical = 16.dp), + placeholder = { CircularProgressIndicator() }, + failure = { PreviewFailed() }, + ) { + when (preview) { + is WidgetPreviewData.Agenda -> + AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true) + is WidgetPreviewData.Month -> + MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme) + } + } + } + } + ConfigOptions( + state = state, + onAppearance = onAppearance, + onAgendaOptions = onAgendaOptions, + modifier = Modifier + .weight(1f) + .background(surface), + ) + } + } + } +} + +@Composable +private fun PreviewFailed() { + Text( + text = stringResource(R.string.widget_config_preview_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +internal val WidgetKind.label: Int + get() = when (this) { + WidgetKind.AGENDA -> R.string.widget_agenda_label + WidgetKind.MONTH -> R.string.widget_month_label + } + +@Composable +private fun ConfigOptions( + state: WidgetConfigUiState.Ready, + onAppearance: (WidgetAppearance) -> Unit, + onAgendaOptions: (AgendaWidgetOptions) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(bottom = 16.dp), + ) { + GroupedSectionHeader(stringResource(R.string.settings_section_appearance)) + GroupedChoiceRow( + title = stringResource(R.string.widget_config_background), + options = WidgetBackground.entries, + selected = state.appearance.background, + label = { backgroundLabel(it) }, + onSelect = { onAppearance(state.appearance.copy(background = it)) }, + position = Position.Top, + ) + GroupedChoiceRow( + title = stringResource(R.string.settings_theme), + options = WidgetTheme.entries, + selected = state.appearance.theme, + label = { themeLabel(it) }, + onSelect = { onAppearance(state.appearance.copy(theme = it)) }, + position = Position.Bottom, + ) + state.agenda?.let { options -> + AgendaOptions(options = options, state = state, onChange = onAgendaOptions) + } + } +} + +@Composable +private fun AgendaOptions( + options: AgendaWidgetOptions, + state: WidgetConfigUiState.Ready, + onChange: (AgendaWidgetOptions) -> Unit, +) { + var showRange by rememberSaveable { mutableStateOf(false) } + var showSize by rememberSaveable { mutableStateOf(false) } + var showPast by rememberSaveable { mutableStateOf(false) } + + GroupedSectionHeader(stringResource(R.string.widget_config_content)) + GroupedRow( + title = stringResource(R.string.widget_config_range), + summary = agendaRangeLabel(options.range), + position = Position.Top, + onClick = { showRange = true }, + ) + GroupedRow( + title = stringResource(R.string.widget_config_text_size), + summary = widgetSizeLabel(options.textSize), + position = Position.Middle, + onClick = { showSize = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)), + position = Position.Middle, + onClick = { showPast = true }, + ) + GroupedSwitchRow( + title = stringResource(R.string.settings_agenda_show_today), + checked = options.showToday, + onCheckedChange = { onChange(options.copy(showToday = it)) }, + position = Position.Bottom, + ) + + if (showRange) { + AgendaRangePicker( + title = stringResource(R.string.widget_config_range), + description = stringResource(R.string.settings_agenda_widget_range_hint), + selected = options.range, + weekStart = state.weekStart, + onSelect = { onChange(options.copy(range = it)) }, + onDismiss = { showRange = false }, + ) + } + if (showSize) { + OptionPicker( + title = stringResource(R.string.widget_config_text_size), + header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, + predictiveBack = true, + options = WidgetSize.entries, + selected = options.textSize, + label = { widgetSizeLabel(it) }, + summary = { widgetSizeSummary(it) }, + onSelect = { onChange(options.copy(textSize = it)) }, + onDismiss = { showSize = false }, + ) + } + if (showPast) { + OptionPicker( + title = stringResource(R.string.settings_past_events), + header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) }, + predictiveBack = true, + options = PastEventDisplay.entries, + selected = options.pastDisplay, + label = { stringResource(pastEventDisplayLabelRes(it)) }, + onSelect = { onChange(options.copy(pastDisplay = it)) }, + onDismiss = { showPast = false }, + ) + } +} + +@Composable +internal fun backgroundLabel(background: WidgetBackground): String = stringResource( + when (background) { + WidgetBackground.SOLID -> R.string.widget_config_background_solid + WidgetBackground.TINTED -> R.string.widget_config_background_tinted + WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent + }, +) + +@Composable +internal fun themeLabel(theme: WidgetTheme): String = stringResource( + when (theme) { + WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app + WidgetTheme.LIGHT -> R.string.settings_theme_light + WidgetTheme.DARK -> R.string.settings_theme_dark + }, +) + +/** + * What a size step does, as a percentage of the smallest one's event text. + * Derived from the widget's own [metricsFor] table rather than hardcoded. + */ +@Composable +private fun widgetSizeSummary(size: WidgetSize): String { + val base = metricsFor(WidgetSize.SMALL).eventTitle.value + val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt() + return stringResource(R.string.settings_widget_size_summary, percent) +} + +@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 + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt new file mode 100644 index 0000000..71ac45a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt @@ -0,0 +1,242 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.widget.AgendaWidgetData +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.MonthWidgetSource +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData +import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource +import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver +import de.jeanlucmakiola.calendula.widget.monthIndexOf +import de.jeanlucmakiola.calendula.widget.monthWindowCenter +import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.putAgendaOptions +import de.jeanlucmakiola.calendula.widget.putAppearance +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetSettings +import de.jeanlucmakiola.calendula.widget.widgetState +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import de.jeanlucmakiola.floret.prefs.ThemeMode +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.DayOfWeek +import java.util.Locale + +/** The widgets a config screen can edit, told apart by their receiver. */ +enum class WidgetKind(val receiver: Class<*>) { + AGENDA(AgendaWidgetReceiver::class.java), + MONTH(MonthWidgetReceiver::class.java), + ; + + fun widget(): GlanceAppWidget = when (this) { + AGENDA -> AgendaWidget() + MONTH -> MonthWidget() + } + + companion object { + fun of(context: Context, appWidgetId: Int): WidgetKind? { + val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null + return entries.firstOrNull { it.receiver.name == provider.className } + } + } +} + +/** What the preview draws, loaded with the draft's options. */ +sealed interface WidgetPreviewData { + data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData + data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData +} + +sealed interface WidgetConfigUiState { + data object Loading : WidgetConfigUiState + + /** The widget is gone, or isn't one of ours; the screen closes. */ + data object Missing : WidgetConfigUiState + + /** Its stored options couldn't be read. */ + data object Failed : WidgetConfigUiState + + data class Ready( + val kind: WidgetKind, + val appearance: WidgetAppearance, + /** Null for a widget with no content options. */ + val agenda: AgendaWidgetOptions?, + val appTheme: ThemeMode, + val weekStart: DayOfWeek, + /** The widget's size on the home screen, which the preview is laid out at. */ + val placedSize: DpSize, + /** Null until the first preview load lands. */ + val preview: WidgetPreviewData?, + /** The first preview load failed, so there's nothing to show. */ + val previewFailed: Boolean = false, + val saving: Boolean = false, + ) : WidgetConfigUiState +} + +/** One-time outcomes of a save, for the screen that's showing when they land. */ +enum class WidgetConfigEvent { SAVED, SAVE_FAILED } + +/** + * Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an + * editable draft, a preview loaded with that draft, and the save that writes it + * into the widget's Glance state and redraws it. + */ +@HiltViewModel +class WidgetConfigViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val prefs: SettingsPrefs, + savedState: SavedStateHandle, +) : ViewModel() { + + val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID + + private val _state = MutableStateFlow(WidgetConfigUiState.Loading) + val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + private var previewJob: Job? = null + + init { + viewModelScope.launch { load() } + } + + fun retry() { + if (_state.value != WidgetConfigUiState.Failed) return + _state.value = WidgetConfigUiState.Loading + viewModelScope.launch { load() } + } + + private suspend fun load() { + val kind = WidgetKind.of(context, appWidgetId) + if (kind == null) { + _state.value = WidgetConfigUiState.Missing + return + } + _state.value = try { + val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)) + val settings = prefs.widgetSettings.first() + WidgetConfigUiState.Ready( + kind = kind, + appearance = stored.widgetAppearance, + agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null, + appTheme = settings.themeMode, + weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), + placedSize = placedSize(kind), + preview = null, + ) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + // getGlanceIdBy: the widget was removed meanwhile. + WidgetConfigUiState.Missing + } catch (_: Exception) { + WidgetConfigUiState.Failed + } + reloadPreview() + } + + /** The size the launcher placed the widget at (portrait), or a default before it has reported one. */ + private fun placedSize(kind: WidgetKind): DpSize { + val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId) + val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0 + val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0 + if (width > 0 && height > 0) return DpSize(width.dp, height.dp) + return when (kind) { + WidgetKind.AGENDA -> DpSize(260.dp, 280.dp) + WidgetKind.MONTH -> DpSize(320.dp, 340.dp) + } + } + + fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) } + + fun setAgendaOptions(options: AgendaWidgetOptions) { + edit { it.copy(agenda = options) } + reloadPreview() + } + + private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) { + _state.update { if (it is WidgetConfigUiState.Ready) change(it) else it } + } + + private fun reloadPreview() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + previewJob?.cancel() + previewJob = viewModelScope.launch { + val preview = try { + loadPreview(ready) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The options stay editable; the preview keeps what it had. + edit { it.copy(previewFailed = it.preview == null) } + return@launch + } + edit { it.copy(preview = preview, previewFailed = false) } + } + } + + private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) { + WidgetKind.AGENDA -> { + val settings = prefs.widgetSettings.first() + WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda))) + } + WidgetKind.MONTH -> { + val index = monthIndexOf(today(systemZone())) + WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index) + } + } + + /** Writes the draft into the widget and redraws it, then reports how that went on [events]. */ + fun save() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + if (ready.saving) return + edit { it.copy(saving = true) } + viewModelScope.launch { + try { + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + ready.kind.widget().updateWithRefresh(context, glanceId) { state -> + state.putAppearance(ready.appearance) + ready.agenda?.let { state.putAgendaOptions(it) } + } + _events.send(WidgetConfigEvent.SAVED) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + _state.value = WidgetConfigUiState.Missing + } catch (_: Exception) { + edit { it.copy(saving = false) } + _events.send(WidgetConfigEvent.SAVE_FAILED) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 53a8815..01d70b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -31,11 +31,12 @@ internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { val clickableSized = sized + 2 /** - * `MonthWidgetBody`'s views outside the week rows: its Column, the header - * row with three sized clickable icon boxes, their sized icons and the - * clickable title, the spacer, and the weekday row of sized labels. + * `MonthWidgetBody`'s views outside the week rows: the surface Box, its + * Column, the header row with three sized clickable icon boxes, their sized + * icons and the clickable title, the spacer, the grid's Column and the + * weekday row of sized labels. */ - val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized + val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + plain + 7 * sized } /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 332726e..c0443ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -26,7 +26,6 @@ import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.color.ColorProvider import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity @@ -63,9 +62,14 @@ import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk -import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.calendula.widget.MonthWidgetSource -import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource +import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource import de.jeanlucmakiola.calendula.widget.monthWindowCenter import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp @@ -134,16 +138,17 @@ class MonthWidget : GlanceAppWidget() { val stamp = refreshStamp(context, id) val initialCenter = monthWindowCenter(shown) // Shared by every size Exact composes, so each loads nothing of its own. - val widgetData = WidgetData(context.loadMonthWidgetSource(id, initialCenter, stamp), initialCenter, stamp) + val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp) provideContent { val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone()) // Reloads when paging leaves the loaded window, and on every redraw, // which a running session would otherwise never see (#420). val center = monthWindowCenter(index) val currentStamp = currentRefreshStamp() - val source = widgetData.current(center, currentStamp) { - context.loadMonthWidgetSource(id, center, currentStamp) + val loaded = widgetData.current(center, currentStamp) { + context.loadCachedMonthSource(id, center, currentStamp) } + val source = loaded.content // A jump out of the loaded window keeps the last month it covers // until the reload lands, rather than drawing an empty grid. var lastShown by remember { mutableIntStateOf(index) } @@ -153,13 +158,24 @@ class MonthWidget : GlanceAppWidget() { else -> index.coerceIn(source.months) } SideEffect { lastShown = shown } - CalendulaGlanceTheme { - MonthWidgetBody(source = source, index = shown) - } + MonthWidgetContent( + source = source, + index = shown, + appearance = currentState().widgetAppearance, + appTheme = loaded.appTheme, + ) } } } +/** The whole month widget showing month [index], shared with its config screen's preview (#292). */ +@Composable +internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + MonthWidgetBody(source = source, index = index) + } +} + /** * Step the displayed month by the `delta` action parameter (±1). * @@ -209,7 +225,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { Column( modifier = GlanceModifier .fillMaxSize() - .background(GlanceTheme.colors.surface) .padding(horizontal = GRID_HPADDING, vertical = 6.dp), ) { when (source) { @@ -226,16 +241,19 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) - WeekdayHeader(weekStart = source.weekStart, colW = colW) - layoutWeeks(weeks, source.today).forEach { layout -> - WeekRow( - layout = layout, - currentMonth = ym.month, - today = source.today, - soften = source.soften, - colW = colW, - modifier = GlanceModifier.defaultWeight(), - ) + // One backing for the whole grid on a transparent widget (#292). + Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { + WeekdayHeader(weekStart = source.weekStart, colW = colW) + layoutWeeks(weeks, source.today).forEach { layout -> + WeekRow( + layout = layout, + currentMonth = ym.month, + today = source.today, + soften = source.soften, + colW = colW, + modifier = GlanceModifier.defaultWeight(), + ) + } } } } @@ -246,7 +264,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { private fun MonthHeader(label: String) { val context = LocalContext.current Row( - modifier = GlanceModifier.fillMaxWidth(), + modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(), verticalAlignment = Alignment.CenterVertically, ) { HeaderIcon( @@ -470,14 +488,14 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { modifier = GlanceModifier .fillMaxSize() .cornerRadius(4.dp) - .background(ColorProvider(day = dayFill, night = nightFill)), + .background(themedColor(day = dayFill, night = nightFill)), contentAlignment = Alignment.CenterStart, ) { Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, style = TextStyle( - color = ColorProvider(day = eventInk(dayFill), night = eventInk(nightFill)), + color = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)), fontSize = 9.sp, textDecoration = glanceDeclinedDecoration(event.isDeclined), ), @@ -520,6 +538,7 @@ private fun PermissionMessage() { fontSize = 14.sp, textAlign = TextAlign.Center, ), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt index 9686a29..7df07dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt @@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.syncWidgetRollover import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState /** * Host-facing receiver for the month widget. Declared in the manifest with the @@ -30,6 +31,11 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { syncWidgetRollover(context) } + /** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */ + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + /** * The `updatePeriodMillis` backstop is the one wake-up the *system* still * owns, so it doubles as the alarm's self-heal: anything that drops a diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ef6379..b778d26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -441,6 +441,21 @@ Previous month Next month Today + Background + Solid + Tinted + Transparent + Follow app + Content + Time range + Text size + Couldn\'t read this widget\'s settings. + Preview unavailable + Couldn\'t save the widget. Try again. + + %1$s %2$d + + %1$s · %2$s Calendars @@ -554,7 +569,7 @@ Event text %1$d%% Always show today - Keep today at the top of the agenda and its widget, even once nothing is left today. + Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting. Range bar Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today @@ -645,12 +660,15 @@ Default fields and behaviour Reminders and delivery Contact birthdays & anniversaries - Agenda widget, Quick Settings tile + Your widgets, Quick Settings tile Export, import, automatic backup Widgets & tiles - Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen. + Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen. + Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen. + On your home screen + No Calendula widgets on your home screen yet. Backup & restore diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index fed97fa..a161487 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -7,4 +7,10 @@ @color/window_background true + + + diff --git a/app/src/main/res/xml/appwidget_info_agenda.xml b/app/src/main/res/xml/appwidget_info_agenda.xml index 12abbe3..b8c707c 100644 --- a/app/src/main/res/xml/appwidget_info_agenda.xml +++ b/app/src/main/res/xml/appwidget_info_agenda.xml @@ -8,6 +8,8 @@ android:minResizeHeight="110dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/widget_agenda_label" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_agenda" diff --git a/app/src/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml index cfa7acb..075aa51 100644 --- a/app/src/main/res/xml/appwidget_info_month.xml +++ b/app/src/main/res/xml/appwidget_info_month.xml @@ -8,6 +8,8 @@ android:minResizeHeight="150dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/widget_month_label" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_month" diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 44101d1..74a425b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -165,15 +165,12 @@ class SettingsPrefsTest { } @Test - fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest { + fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month) - assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month) prefs.setAgendaScreenRange(AgendaRange.Custom(14)) - prefs.setAgendaWidgetRange(AgendaRange.Week) assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14)) - assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } @Test diff --git a/floret-kit b/floret-kit index 96509d0..6dc7bc5 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 96509d0f9d6bd8b8cea65fbe06b93d2ae6088b55 +Subproject commit 6dc7bc50c5509d50a2e69cf34430640c90dd33bd