From e7505235f4e1220629fada1df5744ca33201c6cb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 5 Oct 2026 20:09:12 +0200 Subject: [PATCH] fix(widget): load only what the widgets show (#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Month widget loads the displayed month ±3 months, reloading when paged past it - Agenda widget loads its chosen range, reloading when a wider one is picked --- .../calendula/widget/WidgetData.kt | 67 +++++--- .../calendula/widget/agenda/AgendaWidget.kt | 160 +++++++++++------- .../calendula/widget/month/MonthWidget.kt | 34 +++- 3 files changed, 167 insertions(+), 94 deletions(-) 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 0fa107e0..e902b59a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -11,6 +11,7 @@ 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 @@ -28,11 +29,12 @@ import kotlin.time.Clock import kotlin.time.Instant /** - * How far either side of today the month widget pre-loads. The displayed month - * is chosen reactively in the composition, so one wide read covers ~13 months of - * prev/next navigation without re-querying on every arrow tap. + * Months either side of the displayed one the month widget loads, so a few arrow + * taps page without a re-query. Kept small: every load expands recurrences across + * the whole window, and on slow devices a wide one held the widget on "loading" + * (#363). */ -private const val MONTH_WIDGET_RANGE_DAYS = 400 +private const val MONTH_WIDGET_PAD_MONTHS = 3 internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() @@ -76,6 +78,8 @@ sealed interface AgendaWidgetData { val savedShowToday: Boolean, /** Saved widget size step (#103) — the fallback before Glance state is set. */ val savedWidgetSize: WidgetSize, + /** Last day [days] covers; a wider range picked later needs a reload. */ + val loadedThrough: LocalDate, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -94,14 +98,15 @@ sealed interface MonthWidgetSource { val today: LocalDate, val weekStart: DayOfWeek, val instances: List, + /** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */ + val months: IntRange, ) : MonthWidgetSource } /** - * Process-lived cache of the wide month window. Month navigation re-runs - * `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on - * every arrow tap is what made paging feel sluggish — so we load once and reuse - * the same snapshot for every nearby month. Invalidated by + * Process-lived cache of the month window. Month navigation can re-run + * `provideGlance`, and re-querying on every arrow tap is what made paging feel + * sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by * [invalidateMonthWidgetCache] when calendar data changes (the freshness * receiver), and automatically when the day rolls over (the `today` guard). */ @@ -118,8 +123,11 @@ internal fun invalidateMonthWidgetCache() { * 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. + * + * Loads [range] (the widget's own, or the saved pref when null) rather than the + * widest selectable window (#363). */ -internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { +internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) @@ -129,11 +137,11 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val savedPastDisplay = prefs.pastEventDisplay.first() val showToday = prefs.agendaShowToday.first() val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) - // 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 windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY) + // The displayed range is still sliced in the composition from Glance state; + // a wider one picked later reloads there (see loadedThrough). + val dayCount = (range ?: savedRange).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 is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) @@ -148,22 +156,39 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { savedPastDisplay = savedPastDisplay, savedShowToday = showToday, savedWidgetSize = prefs.widgetSize.first(), + loadedThrough = windowEnd, now = Clock.System.now(), ) } -/** One-shot wide read backing the month widget's grid for any nearby month. */ -internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource { +internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal + +private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1) + +/** Read backing the month widget's grid for [centerIndex] and the months around it. */ +internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() val anchor = today(zone) + val center = centerIndex ?: monthIndexOf(anchor) // Reuse the cached window unless the day changed (then it's stale for "today"). - MonthWidgetCache.data?.let { if (it.today == anchor) return it } + MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it } val ep = widgetEntryPoint() val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault()) - val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone) - val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) + val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS) + // Padded by the grid's spill into the neighbouring months. + val from = firstDayOfMonthIndex(months.first) + .minus(1, DateTimeUnit.WEEK) + .atStartOfDayIn(zone) + val to = firstDayOfMonthIndex(months.last + 1) + .plus(2, DateTimeUnit.WEEK) + .atTime(23, 59, 59) + .toInstant(zone) val instances = ep.calendarRepository().instances(from..to).first() - return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances) - .also { MonthWidgetCache.data = it } + return MonthWidgetSource.Ready( + today = anchor, + weekStart = weekStart, + instances = instances, + months = months, + ).also { MonthWidgetCache.data = it } } 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 930c1ebc..548572ce 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 @@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter @@ -28,6 +34,7 @@ import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.background import androidx.glance.currentState +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box @@ -125,7 +132,8 @@ class AgendaWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { - val data = context.loadAgendaWidgetData() + val stored = getAppWidgetState(context, id)[AGENDA_RANGE_KEY] + val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) }) val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES provideContent { @@ -190,71 +198,95 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) 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. + // to the saved pref for a freshly placed widget), then the loaded + // window is sliced to it — pure recomposition. val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) - val pastDisplay = - parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) - val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday - val rangeEnd = data.today.plus( - range.dayCount(data.today, data.weekStart) - 1, - DateTimeUnit.DAY, - ) - // Slice to the chosen range, then drop finished events (and any day - // they leave empty) when the mode is Hide — so "Upcoming" really is. - // Finally re-anchor today (as an empty placeholder day) when the - // "always show today" pref is on and today has no events left (#35). - val visibleDays = anchorTodayIfMissing( - days = data.days - .filter { it.date <= rangeEnd } - .let { days -> - if (pastDisplay == PastEventDisplay.HIDE) { - days.mapNotNull { day -> - val remaining = day.events.filterNot { it.hasEnded(data.now) } - if (remaining.isEmpty()) null else day.copy(events = remaining) - } - } else { - days - } - }, - today = data.today, - enabled = showToday, - ) - if (visibleDays.isEmpty()) { - WidgetMessage(R.string.agenda_empty_title, metrics) + val context = androidx.glance.LocalContext.current + var loaded by remember(data) { mutableStateOf(data) } + // A range wider than what was loaded reloads in a live session, + // where provideGlance doesn't run again (#363). + LaunchedEffect(range) { + val end = loaded.today.plus( + range.dayCount(loaded.today, loaded.weekStart) - 1, + DateTimeUnit.DAY, + ) + if (end > loaded.loadedThrough) { + (context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready) + ?.let { loaded = it } + } + } + AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics) + } + } + } +} + +@Composable +private fun AgendaRows( + data: AgendaWidgetData.Ready, + range: AgendaRange, + dark: Boolean, + metrics: AgendaMetrics, +) { + val pastDisplay = + parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) + val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday + val rangeEnd = data.today.plus( + range.dayCount(data.today, data.weekStart) - 1, + DateTimeUnit.DAY, + ) + // Slice to the chosen range, then drop finished events (and any day + // they leave empty) when the mode is Hide — so "Upcoming" really is. + // Finally re-anchor today (as an empty placeholder day) when the + // "always show today" pref is on and today has no events left (#35). + val visibleDays = anchorTodayIfMissing( + days = data.days + .filter { it.date <= rangeEnd } + .let { days -> + if (pastDisplay == PastEventDisplay.HIDE) { + days.mapNotNull { day -> + val remaining = day.events.filterNot { it.hasEnded(data.now) } + if (remaining.isEmpty()) null else day.copy(events = remaining) + } } else { - val rows = buildList { - visibleDays.forEach { day -> - add(AgendaRow.Header(day.date, data.today)) - if (day.events.isEmpty()) { - add(AgendaRow.Placeholder(day.date)) - } else { - day.events.forEach { add(AgendaRow.Event(day.date, it)) } - } - } - } - // Bound the payload, then drop a day header the cut left - // 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, - dark = dark, - soften = data.soften, - is24Hour = data.is24Hour, - dimmed = pastDisplay == PastEventDisplay.DIM && - row.event.hasEnded(data.now), - metrics = metrics, - ) - } - } - } + days + } + }, + today = data.today, + enabled = showToday, + ) + if (visibleDays.isEmpty()) { + WidgetMessage(R.string.agenda_empty_title, metrics) + } else { + val rows = buildList { + visibleDays.forEach { day -> + add(AgendaRow.Header(day.date, data.today)) + if (day.events.isEmpty()) { + add(AgendaRow.Placeholder(day.date)) + } else { + day.events.forEach { add(AgendaRow.Event(day.date, it)) } + } + } + } + // Bound the payload, then drop a day header the cut left + // 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, + dark = dark, + soften = data.soften, + is24Hour = data.is24Hour, + dimmed = pastDisplay == PastEventDisplay.DIM && + row.event.hasEnded(data.now), + metrics = metrics, + ) } } } 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 43455e68..c72e851b 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 @@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.intPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId @@ -26,6 +32,7 @@ import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.background import androidx.glance.currentState @@ -59,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource +import de.jeanlucmakiola.calendula.widget.monthIndexOf import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.widgetEntryPoint @@ -81,10 +89,7 @@ private val LANE_HEIGHT = 14.dp private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp -private fun currentMonthIndex(zone: TimeZone): Int { - val t = today(zone) - return t.year * 12 + t.month.ordinal -} +private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) private fun yearMonthOf(index: Int): YearMonth = YearMonth(index / 12, Month(index % 12 + 1)) @@ -123,7 +128,8 @@ class MonthWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { - val source = context.loadMonthWidgetSource() + val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY] + val source = context.loadMonthWidgetSource(shown) val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES // Read fresh (not through the cached source) so toggling the softener @@ -198,18 +204,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo val zone = systemZone() val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val ym = yearMonthOf(index) + // Paging past the loaded months reloads around the new one in a + // live session, where provideGlance doesn't run again. + val context = LocalContext.current + var loaded by remember(source) { mutableStateOf(source) } + LaunchedEffect(index) { + if (index !in loaded.months) { + (context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready) + ?.let { loaded = it } + } + } // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 - val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) + val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone) - MonthHeader(label = monthLabel(ym, source.today.year)) + MonthHeader(label = monthLabel(ym, loaded.today.year)) Spacer(GlanceModifier.height(2.dp)) - WeekdayHeader(weekStart = source.weekStart, colW = colW) + WeekdayHeader(weekStart = loaded.weekStart, colW = colW) weeks.forEach { week -> WeekRow( week = week, currentMonth = ym.month, - today = source.today, + today = loaded.today, dark = dark, soften = soften, colW = colW,