Widgets: one kind registry and receiver base, per-widget calendar choice (#292)

This commit is contained in:
makiolaj committed 2026-10-23 18:51:49 +02:00
1 parent 0ecd6de27b
commit edf80fe8b8
14 files changed
+358 -225

No files matched your search

@@ -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
@@ -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)
}
}
@@ -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<Long>
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
}
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -96,6 +96,12 @@ internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
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<AgendaWidgetData> {
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<AgendaWidget
internal suspend fun Context.loadAgendaWidgetData(
settings: WidgetSettings,
options: AgendaWidgetOptions,
hiddenCalendars: Set<Long>,
): 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<MonthWidgetSource> =
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<MonthWidgetSource> {
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
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<Long>,
): 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,
)
}
@@ -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<WidgetOption>,
/** 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 }
}
}
}
@@ -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<Class<*>> = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = 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()
}
}
@@ -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)
}
}
@@ -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
@@ -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<CalendarSource>,
hidden: Set<Long>,
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<CalendarSource>, hidden: Set<Long>): 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)
}
}
@@ -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,
)
}
}
@@ -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(
@@ -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<Long>,
)
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<CalendarSource>,
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<CalendarSource> {
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) {
@@ -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)
}
}
+8
View File
@@ -456,6 +456,14 @@
<string name="widget_numbered">%1$s %2$d</string>
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
<string name="widget_appearance_summary">%1$s · %2$s</string>
<string name="widget_config_calendars">Calendars</string>
<string name="widget_config_calendars_hint">The calendars this widget shows. Calendars you hid in the app stay hidden here too.</string>
<string name="widget_config_calendars_all">All calendars</string>
<!-- How many calendars a widget shows: "2 of 5 calendars" -->
<plurals name="widget_config_calendars_some">
<item quantity="one">%1$d of %2$d calendar</item>
<item quantity="other">%1$d of %2$d calendars</item>
</plurals>
<!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string>