From edf80fe8b84a5007dcf40e0c4d5492eefaf34327 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:19:48 +0200 Subject: [PATCH] Widgets: one kind registry and receiver base, per-widget calendar choice (#292) --- .../calendula/ui/settings/WidgetSettings.kt | 3 +- .../widget/CalendulaWidgetReceiver.kt | 44 +++++++ .../calendula/widget/WidgetConfig.kt | 17 +++ .../calendula/widget/WidgetData.kt | 29 +++-- .../calendula/widget/WidgetKind.kt | 57 +++++++++ .../calendula/widget/WidgetRedraw.kt | 51 +++------ .../widget/agenda/AgendaWidgetReceiver.kt | 51 +-------- .../calendula/widget/config/PlacedWidgets.kt | 1 + .../widget/config/WidgetCalendarPicker.kt | 60 ++++++++++ .../widget/config/WidgetConfigActivity.kt | 3 +- .../widget/config/WidgetConfigScreen.kt | 98 ++++++++++------ .../widget/config/WidgetConfigViewModel.kt | 108 ++++++++++-------- .../widget/month/MonthWidgetReceiver.kt | 53 +-------- app/src/main/res/values/strings.xml | 8 ++ 14 files changed, 358 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt 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 aad1d35..c383197 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 @@ -29,11 +29,10 @@ 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 de.jeanlucmakiola.calendula.widget.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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt new file mode 100644 index 0000000..dbc12e0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.calendula.widget + +import android.annotation.SuppressLint +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState + +/** + * What every widget receiver shares: the midnight rollover follows whether any + * widget is placed (#228), a restore keeps each widget's settings (#292), and + * the system's `updatePeriodMillis` wake-up reloads the data and re-arms the + * alarm — anything that drops a pending alarm without a broadcast (force-stop, + * battery restriction, an OEM freeze) is repaired there rather than on the + * next app open. + */ +abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() { + + override fun onEnabled(context: Context) { + super.onEnabled(context) + syncWidgetRollover(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + syncWidgetRollover(context) + } + + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + + @SuppressLint("MissingSuperCall") + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + // In place of super, which only recomposes and would take the goAsync handle. + reloadOnUpdate(context, appWidgetIds) + syncWidgetRollover(context) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt index 69ac74d..32258b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -5,10 +5,12 @@ 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.datastore.preferences.core.stringSetPreferencesKey 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.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue @@ -50,6 +52,7 @@ 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") +private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars") internal val Preferences.widgetAppearance: WidgetAppearance get() = WidgetAppearance( @@ -84,5 +87,19 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { this[PastDisplayKey] = options.pastDisplay.name } +/** + * Calendars this widget leaves out, on top of the ones hidden app-wide. Stored + * as the excluded set, so a calendar added later shows up by default. + */ +internal val Preferences.hiddenCalendars: Set + get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty() + +internal fun MutablePreferences.putHiddenCalendars(ids: Set) { + this[HiddenCalendarsKey] = ids.map(Long::toString).toSet() +} + +internal fun List.withoutCalendars(hidden: Set): List = + if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden } + 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 dca5a54..61a0725 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -96,6 +96,12 @@ internal val SettingsPrefs.widgetSettings: Flow internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() +/** + * The locale widget text is written in. Glance has no LocalConfiguration, and a + * widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough. + */ +internal fun widgetLocale(): Locale = Locale.getDefault() + internal fun today(zone: TimeZone): LocalDate = Clock.System.now().toLocalDateTime(zone).date @@ -145,8 +151,9 @@ sealed interface 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) + val state = widgetState(id) + val options = state.agendaWidgetOptions(settings.agendaDefaults) + return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode) } /** @@ -157,6 +164,7 @@ internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed, ): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() @@ -166,7 +174,7 @@ internal suspend fun Context.loadAgendaWidgetData( 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() + val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars) return AgendaWidgetData.Ready( today = anchor, days = groupAgendaDays(anchor, windowEnd, instances, zone), @@ -214,15 +222,20 @@ private object MonthSourceCache { /** [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) - ?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } + ?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars) + .also { MonthSourceCache.put(id, centerIndex, stamp, it) } /** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */ -internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed { +internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set): Themed { val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() - return Themed(queryMonthSource(centerIndex, settings), settings.themeMode) + return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode) } -private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource { +private suspend fun Context.queryMonthSource( + centerIndex: Int, + settings: WidgetSettings, + hiddenCalendars: Set, +): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() val ep = widgetEntryPoint() @@ -239,7 +252,7 @@ private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetS today = today(zone), weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), soften = settings.soften, - instances = ep.calendarRepository().instances(from..to).first(), + instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars), months = months, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt new file mode 100644 index 0000000..8cbb078 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.widget + +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 de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver + +/** A content option a widget's config screen offers, besides background and theme. */ +enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS } + +/** + * Every widget the app ships, told apart by their receiver: what redraws, + * the rollover and the config screen go through. + */ +enum class WidgetKind( + val receiver: Class<*>, + val label: Int, + val options: Set, + /** The size a config preview draws at before the launcher reports one. */ + val defaultSize: DpSize, +) { + AGENDA( + AgendaWidgetReceiver::class.java, + R.string.widget_agenda_label, + setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS), + DpSize(260.dp, 280.dp), + ), + MONTH( + MonthWidgetReceiver::class.java, + R.string.widget_month_label, + setOf(WidgetOption.CALENDARS), + DpSize(320.dp, 340.dp), + ), + ; + + fun widget(): GlanceAppWidget = when (this) { + AGENDA -> AgendaWidget() + MONTH -> MonthWidget() + } + + /** Whether this kind stores [AgendaWidgetOptions] of its own. */ + val hasContentOptions: Boolean + get() = options.any { it != WidgetOption.CALENDARS } + + 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 } + } + } +} 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 5425900..ec8bd90 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -9,10 +9,6 @@ import android.os.SystemClock import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.ApplicationScope import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver -import de.jeanlucmakiola.calendula.widget.month.MonthWidget -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 @@ -33,10 +29,7 @@ import javax.inject.Inject import javax.inject.Singleton /** Every widget receiver; the set [WidgetRollover] checks for placed instances. */ -internal val WIDGET_RECEIVER_CLASSES: List> = listOf( - MonthWidgetReceiver::class.java, - AgendaWidgetReceiver::class.java, -) +internal val WIDGET_RECEIVER_CLASSES: List> = WidgetKind.entries.map { it.receiver } /** The app's own midnight wake-up (#228). */ internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" @@ -57,31 +50,28 @@ internal fun syncWidgetRollover(context: Context) = /** * Reloads and redraws every placed widget, a running session included: the - * refresh stamp moves, and both widgets key their data load on it (#420). + * refresh stamp moves, and every widget keys its data load on it (#420). */ internal suspend fun redrawWidgets(context: Context) { - redrawAgendaWidgets(context) - redrawMonthWidgets(context) + WidgetKind.entries.forEach { redrawWidgets(context, it) } } -private suspend fun redrawAgendaWidgets(context: Context) = - AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) +internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) = + kind.widget().updatePlaced(context, kind.receiver, 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) { +/** Redraws only the widgets with no content options of their own, which still follow the app's. */ +private suspend fun redrawUnconfiguredWidgets(context: Context) { val appContext = context.applicationContext - val ids = AppWidgetManager.getInstance(appContext) - ?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return + val manager = AppWidgetManager.getInstance(appContext) ?: return val glanceManager = GlanceAppWidgetManager(appContext) - ids.forEach { appWidgetId -> - val glanceId = glanceManager.getGlanceIdBy(appWidgetId) - if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId) + WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind -> + manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId) + } } } -private suspend fun redrawMonthWidgets(context: Context) = - MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true) - /** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */ private val lastLocalChange = AtomicLong(0L) @@ -97,7 +87,7 @@ internal fun isProviderEcho(): Boolean { * 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. The app's agenda options only redraw - * agenda widgets that have none of their own (#292). Started once from + * widgets that have no content options of their own (#292). Started once from * `CalendulaApp`. */ @Singleton @@ -112,20 +102,17 @@ class WidgetRefreshCoordinator @Inject constructor( /** Subscribes before returning, so a change made right after is never taken for the starting value. */ 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) } + val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } } + val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) } watch(repository.localChanges) { lastLocalChange.set(SystemClock.elapsedRealtime()) - agenda.request() - month.request() + all.forEach { it.request() } } watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { - agenda.request() - month.request() + all.forEach { it.request() } } watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { - unconfiguredAgenda.request() + unconfigured.request() } } 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 ee48a12..01eab36 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 @@ -1,54 +1,9 @@ package de.jeanlucmakiola.calendula.widget.agenda -import android.annotation.SuppressLint -import android.appwidget.AppWidgetManager -import android.content.Context 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 +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver -/** - * Host-facing receiver for the agenda widget. Declared in the manifest with the - * `appwidget_info_agenda` provider metadata; delegates all rendering to - * [AgendaWidget]. - */ -class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { +/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */ +class AgendaWidgetReceiver : CalendulaWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AgendaWidget() - - /** First agenda widget placed — start rolling "today" over at midnight (#228). */ - override fun onEnabled(context: Context) { - super.onEnabled(context) - syncWidgetRollover(context) - } - - /** - * Last agenda widget removed. [syncWidgetRollover] cancels only if - * no month widget is left either. - */ - override fun onDisabled(context: Context) { - super.onDisabled(context) - 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]. - */ - @SuppressLint("MissingSuperCall") - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - // In place of super, which only recomposes and would take the goAsync handle. - reloadOnUpdate(context, appWidgetIds) - syncWidgetRollover(context) - } } 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 index 4425ac4..458e060 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Context import androidx.glance.appwidget.GlanceAppWidgetManager import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetKind import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.calendula.widget.widgetState diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt new file mode 100644 index 0000000..a9122e6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.material3.Checkbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle +import de.jeanlucmakiola.calendula.ui.common.groupByAccount +import de.jeanlucmakiola.calendula.ui.common.orUnnamed +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf + +/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */ +@Composable +internal fun WidgetCalendarPicker( + calendars: List, + hidden: Set, + onSetShown: (Long, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.widget_config_calendars), + onDismiss = onDismiss, + predictiveBack = true, + ) { + GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp) + calendars.groupByAccount().forEach { group -> + GroupedSectionHeader(accountGroupTitle(group)) + group.calendars.forEachIndexed { index, calendar -> + val shown = calendar.id !in hidden + GroupedRow( + title = calendar.displayName.orUnnamed(), + position = positionOf(index, group.calendars.size), + minHeight = 56.dp, + leading = { CalendarColorChip(calendar.color) }, + trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) }, + onClick = { onSetShown(calendar.id, !shown) }, + ) + } + } + } +} + +/** "All calendars", or how many of them the widget shows. */ +@Composable +internal fun calendarsSummary(calendars: List, hidden: Set): String { + val shown = calendars.count { it.id !in hidden } + return if (shown == calendars.size) { + stringResource(R.string.widget_config_calendars_all) + } else { + pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size) + } +} 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 index 12cddfb..bf8fafa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt @@ -113,7 +113,8 @@ class WidgetConfigActivity : ComponentActivity() { onSave = viewModel::save, onRetry = viewModel::retry, onAppearance = viewModel::setAppearance, - onAgendaOptions = viewModel::setAgendaOptions, + onContent = viewModel::setContent, + onCalendarShown = viewModel::setCalendarShown, ) } } 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 index 603e29e..004eac0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -42,6 +42,7 @@ 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.WidgetOption import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetTheme import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent @@ -54,6 +55,7 @@ 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.components.positionOf import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview import de.jeanlucmakiola.floret.glance.WallpaperWindow import de.jeanlucmakiola.floret.glance.WidgetBackground @@ -69,7 +71,8 @@ internal fun WidgetConfigScreen( onSave: () -> Unit, onRetry: () -> Unit, onAppearance: (WidgetAppearance) -> Unit, - onAgendaOptions: (AgendaWidgetOptions) -> Unit, + onContent: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, ) { val ready = state as? WidgetConfigUiState.Ready val surface = MaterialTheme.colorScheme.surface @@ -150,7 +153,8 @@ internal fun WidgetConfigScreen( ConfigOptions( state = state, onAppearance = onAppearance, - onAgendaOptions = onAgendaOptions, + onContent = onContent, + onCalendarShown = onCalendarShown, modifier = Modifier .weight(1f) .background(surface), @@ -173,17 +177,12 @@ private fun PreviewFailed() { ) } -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, + onContent: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -209,47 +208,72 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(theme = it)) }, position = Position.Bottom, ) - state.agenda?.let { options -> - AgendaOptions(options = options, state = state, onChange = onAgendaOptions) + if (state.kind.options.isNotEmpty()) { + ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown) } } } @Composable -private fun AgendaOptions( - options: AgendaWidgetOptions, +private fun ContentOptions( state: WidgetConfigUiState.Ready, onChange: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, ) { + val options = state.draft.content var showRange by rememberSaveable { mutableStateOf(false) } var showSize by rememberSaveable { mutableStateOf(false) } var showPast by rememberSaveable { mutableStateOf(false) } + var showCalendars by rememberSaveable { mutableStateOf(false) } + + // Rows in WidgetOption order, each shaped by where it lands among the kind's own. + val rows = WidgetOption.entries.filter { it in state.kind.options } + fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size) 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, - ) + rows.forEach { option -> + when (option) { + WidgetOption.RANGE -> GroupedRow( + title = stringResource(R.string.widget_config_range), + summary = agendaRangeLabel(options.range), + position = position(option), + onClick = { showRange = true }, + ) + WidgetOption.TEXT_SIZE -> GroupedRow( + title = stringResource(R.string.widget_config_text_size), + summary = widgetSizeLabel(options.textSize), + position = position(option), + onClick = { showSize = true }, + ) + WidgetOption.PAST_DISPLAY -> GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)), + position = position(option), + onClick = { showPast = true }, + ) + WidgetOption.SHOW_TODAY -> GroupedSwitchRow( + title = stringResource(R.string.settings_agenda_show_today), + checked = options.showToday, + onCheckedChange = { onChange(options.copy(showToday = it)) }, + position = position(option), + ) + WidgetOption.CALENDARS -> GroupedRow( + title = stringResource(R.string.widget_config_calendars), + summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars), + position = position(option), + onClick = { showCalendars = true }, + ) + } + } + + if (showCalendars) { + WidgetCalendarPicker( + calendars = state.calendars, + hidden = state.draft.hiddenCalendars, + onSetShown = onCalendarShown, + onDismiss = { showCalendars = false }, + ) + } if (showRange) { AgendaRangePicker( 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 index 71ac45a..696af8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt @@ -4,25 +4,27 @@ 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.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.WidgetOption +import de.jeanlucmakiola.calendula.widget.hiddenCalendars +import de.jeanlucmakiola.calendula.widget.putHiddenCalendars 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 @@ -50,31 +52,20 @@ 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 } +/** The options being edited, applied to the preview as they change and written on save. */ +data class WidgetDraft( + val appearance: WidgetAppearance, + /** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */ + val content: AgendaWidgetOptions, + val hiddenCalendars: Set, +) + sealed interface WidgetConfigUiState { data object Loading : WidgetConfigUiState @@ -86,9 +77,9 @@ sealed interface WidgetConfigUiState { data class Ready( val kind: WidgetKind, - val appearance: WidgetAppearance, - /** Null for a widget with no content options. */ - val agenda: AgendaWidgetOptions?, + val draft: WidgetDraft, + /** The calendars the app shows, for the widget's own calendar choice. */ + val calendars: List, val appTheme: ThemeMode, val weekStart: DayOfWeek, /** The widget's size on the home screen, which the preview is laid out at. */ @@ -98,7 +89,9 @@ sealed interface WidgetConfigUiState { /** The first preview load failed, so there's nothing to show. */ val previewFailed: Boolean = false, val saving: Boolean = false, - ) : WidgetConfigUiState + ) : WidgetConfigUiState { + val appearance: WidgetAppearance get() = draft.appearance + } } /** One-time outcomes of a save, for the screen that's showing when they land. */ @@ -113,6 +106,8 @@ enum class WidgetConfigEvent { SAVED, SAVE_FAILED } class WidgetConfigViewModel @Inject constructor( @ApplicationContext private val context: Context, private val prefs: SettingsPrefs, + private val calendarPrefs: CalendarPrefs, + private val repository: CalendarRepository, savedState: SavedStateHandle, ) : ViewModel() { @@ -147,8 +142,12 @@ class WidgetConfigViewModel @Inject constructor( val settings = prefs.widgetSettings.first() WidgetConfigUiState.Ready( kind = kind, - appearance = stored.widgetAppearance, - agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null, + draft = WidgetDraft( + appearance = stored.widgetAppearance, + content = stored.agendaWidgetOptions(settings.agendaDefaults), + hiddenCalendars = stored.hiddenCalendars, + ), + calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(), appTheme = settings.themeMode, weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), placedSize = placedSize(kind), @@ -171,16 +170,27 @@ class WidgetConfigViewModel @Inject constructor( 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) - } + return kind.defaultSize } - fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) } + /** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */ + private suspend fun shownCalendars(): List { + val hidden = calendarPrefs.hiddenCalendarIds.first() + return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden } + } - fun setAgendaOptions(options: AgendaWidgetOptions) { - edit { it.copy(agenda = options) } + fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) } + + fun setContent(options: AgendaWidgetOptions) { + edit { it.copy(draft = it.draft.copy(content = options)) } + reloadPreview() + } + + fun setCalendarShown(id: Long, shown: Boolean) { + edit { + val hidden = it.draft.hiddenCalendars + it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id)) + } reloadPreview() } @@ -205,14 +215,17 @@ class WidgetConfigViewModel @Inject constructor( } } - 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) + private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData { + val draft = ready.draft + val settings = prefs.widgetSettings.first() + return when (ready.kind) { + WidgetKind.AGENDA -> + WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars)) + WidgetKind.MONTH -> { + val index = monthIndexOf(today(systemZone())) + val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars) + WidgetPreviewData.Month(source.content, index) + } } } @@ -225,8 +238,9 @@ class WidgetConfigViewModel @Inject constructor( try { val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) ready.kind.widget().updateWithRefresh(context, glanceId) { state -> - state.putAppearance(ready.appearance) - ready.agenda?.let { state.putAgendaOptions(it) } + state.putAppearance(ready.draft.appearance) + if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content) + if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars) } _events.send(WidgetConfigEvent.SAVED) } catch (e: CancellationException) { 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 7df07dc..f645a15 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 @@ -1,56 +1,9 @@ package de.jeanlucmakiola.calendula.widget.month -import android.annotation.SuppressLint -import android.appwidget.AppWidgetManager -import android.content.Context 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 +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver -/** - * Host-facing receiver for the month widget. Declared in the manifest with the - * `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget]. - */ -class MonthWidgetReceiver : GlanceAppWidgetReceiver() { +/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */ +class MonthWidgetReceiver : CalendulaWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = MonthWidget() - - /** First month widget placed — start rolling "today" over at midnight (#228). */ - override fun onEnabled(context: Context) { - super.onEnabled(context) - syncWidgetRollover(context) - } - - /** - * Last month widget removed. [syncWidgetRollover] cancels only if - * no agenda widget is left either. - */ - override fun onDisabled(context: Context) { - super.onDisabled(context) - 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 - * pending alarm without a broadcast (force-stop, battery restriction, an OEM - * freeze) is repaired here rather than on the next app open. Re-arming - * closer to midnight also narrows the inexact delivery window. - */ - @SuppressLint("MissingSuperCall") - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - // In place of super, which only recomposes and would take the goAsync handle. - reloadOnUpdate(context, appWidgetIds) - syncWidgetRollover(context) - } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b778d26..4fe6fa1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,6 +456,14 @@ %1$s %2$d %1$s · %2$s + Calendars + The calendars this widget shows. Calendars you hid in the app stay hidden here too. + All calendars + + + %1$d of %2$d calendar + %1$d of %2$d calendars + Calendars