From c30a1535524235027cb9b3808f226686853f9b1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 17:03:48 +0200 Subject: [PATCH] fix(widget): drive agenda range via reactive Glance state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agenda widget loaded and sliced its data in the provideGlance preamble and captured it as a non-reactive local. updateAll() reliably recomposes a live Glance session but does not reliably re-run that preamble, so a range change was redrawn against the stale slice (intermittently — only when no session was alive did it pick up the new range). This is the same platform limitation the month widget already works around. Mirror that pattern: load the widest selectable window once, store the range in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via currentState, and slice in the composition. The settings setter writes the state into each instance and recomposes, so a range change now reflects via plain recomposition regardless of session lifecycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ISSUE_TEMPLATE/crash_report.md | 1 + .gitea/ISSUE_TEMPLATE/feature_request.md | 2 +- .gitea/ISSUE_TEMPLATE/question.md | 19 +++++++++++ .../ui/settings/SettingsViewModel.kt | 16 ++++++++- .../calendula/widget/WidgetData.kt | 21 ++++++++++-- .../calendula/widget/agenda/AgendaWidget.kt | 34 +++++++++++++++++-- 6 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/question.md diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.gitea/ISSUE_TEMPLATE/crash_report.md index c76b1b7..6828788 100644 --- a/.gitea/ISSUE_TEMPLATE/crash_report.md +++ b/.gitea/ISSUE_TEMPLATE/crash_report.md @@ -5,6 +5,7 @@ title: "Crash: " labels: - bug - crash + - priority:high --- + + +### Context +- Calendula version: +- Android version: +- Device: 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 4adf344..b9516a6 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 @@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context import android.os.Build +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.updateAll import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -16,7 +18,9 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.flow.Flow @@ -171,7 +175,17 @@ class SettingsViewModel @Inject constructor( fun setAgendaWidgetRange(range: AgendaRange) { viewModelScope.launch { prefs.setAgendaWidgetRange(range) - widgetRefreshMutex.withLock { AgendaWidget().updateAll(appContext) } + widgetRefreshMutex.withLock { + // Push the range into each instance's Glance state and recompose. + // The widget reads it via currentState, so this reflects reliably + // even when updateAll only recomposes a live session (it does not + // re-run provideGlance's data preamble). + val manager = GlanceAppWidgetManager(appContext) + manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> + updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() } + } + AgendaWidget().updateAll(appContext) + } } } 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 ac530cf..468939b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -8,8 +8,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +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 kotlinx.coroutines.flow.first import kotlinx.datetime.DateTimeUnit @@ -47,9 +47,19 @@ sealed interface AgendaWidgetData { data object NeedsPermission : AgendaWidgetData data class Ready( val today: LocalDate, + /** + * Days with events across the **widest** selectable window. The displayed + * range is sliced from this in the composition (read reactively from + * Glance state), so a range change is pure recomposition rather than a + * flaky widget session restart — the same approach the month widget uses. + */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, + /** First day of the week, for the calendar-aligned "this week" range. */ + val weekStart: DayOfWeek, + /** Saved range pref — the fallback when an instance has no Glance state yet. */ + val savedRange: AgendaRange, ) : AgendaWidgetData } @@ -97,9 +107,12 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val anchor = today(zone) val ep = widgetEntryPoint() val prefs = ep.settingsPrefs() - val range = prefs.agendaWidgetRange.first() + val savedRange = prefs.agendaWidgetRange.first() val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) - val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) + // Load the widest selectable window once; the displayed range is sliced in + // the composition from Glance state, so changing the range is a plain + // recomposition and never depends on the widget session restarting. + val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) val instances = ep.calendarRepository().instances(window).first() val is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) @@ -107,6 +120,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { today = anchor, days = groupAgendaDays(anchor, instances, zone), is24Hour = is24Hour, + weekStart = weekStart, + savedRange = savedRange, ) } 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 a2a28c3..4d5c77d 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column @@ -41,6 +44,9 @@ import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.dayCount +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -63,8 +69,20 @@ import java.util.Locale * of events grouped under day headers (the Google "Schedule" widget model). * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ +/** + * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]). + * The range is read reactively in the composition ([currentState]) so a settings + * change is reflected by plain recomposition — `updateAll` does NOT reliably + * re-run the `provideGlance` preamble for a live widget session, so loading the + * range there and slicing it would silently keep the stale value (mirrors the + * month widget's MONTH_INDEX_KEY approach). + */ +internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range") + class AgendaWidget : GlanceAppWidget() { + override val stateDefinition = PreferencesGlanceStateDefinition + override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -102,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) - is AgendaWidgetData.Ready -> - if (data.days.isEmpty()) { + is AgendaWidgetData.Ready -> { + // Range read reactively from per-instance Glance state (falls back + // to the saved pref for a freshly placed widget), then the wide + // pre-loaded window is sliced to it — pure recomposition. + val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) + val rangeEnd = data.today.plus( + range.dayCount(data.today, data.weekStart) - 1, + DateTimeUnit.DAY, + ) + val visibleDays = data.days.filter { it.date <= rangeEnd } + if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title) } else { val rows = buildList { - data.days.forEach { day -> + visibleDays.forEach { day -> add(AgendaRow.Header(day.date, data.today)) day.events.forEach { add(AgendaRow.Event(it)) } } @@ -121,6 +148,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { } } } + } } } }