Share widget loads across sizes, redraw on own changes only (#420, #229)

This commit is contained in:
makiolaj committed 2026-10-21 17:02:43 +02:00
1 parent 77e103c66c
commit 225901ceee
11 files changed
+69 -65

No files matched your search

@@ -48,19 +48,20 @@ class CalendulaApp : Application() {
reconcileSpecialDates()
reconcileCalendarVisibility()
startReminderDelivery()
reconcileWidgetRollover()
widgetEntryPoint().widgetRefreshCoordinator().start()
startWidgets()
}
/**
* Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* (#228), and start redrawing them on the app's own changes (#420). The
* re-arm is idempotent and covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start.
*/
private fun reconcileWidgetRollover() {
private fun startWidgets() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
syncWidgetRollover(this@CalendulaApp)
widgetEntryPoint().widgetRefreshCoordinator().start()
}
}
@@ -19,8 +19,8 @@ interface CalendarRepository {
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
/** Emits whenever [instances] would re-query: a provider change, an own write, or a visibility change. */
val changes: Flow<Unit>
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
val localChanges: Flow<Unit>
suspend fun eventDetail(eventId: Long): EventDetail
@@ -78,17 +78,24 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit)
}
private val writes = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
writes.tryEmit(Unit)
}
}
override val changes: Flow<Unit> = merge(
ticks,
override val localChanges: Flow<Unit> = merge(
writes,
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
)
@@ -46,7 +46,6 @@ import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.redrawWidgets
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -58,8 +57,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -172,17 +169,6 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws, so a quick run of setting changes lands in order.
private val widgetRefreshMutex = Mutex()
/** Saves a setting the widgets draw with, then reloads them. */
private fun saveAndRedrawWidgets(block: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch {
prefs.block()
widgetRefreshMutex.withLock { redrawWidgets(appContext) }
}
}
/** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates
@@ -265,7 +251,7 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
fun setSoftenColors(enabled: Boolean) = saveAndRedrawWidgets { setSoftenCalendarColors(enabled) }
fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
fun setFont(role: FontRole, token: String) = save {
when (role) {
@@ -293,20 +279,20 @@ class SettingsViewModel @Inject constructor(
}
}
fun setWeekStart(pref: WeekStartPref) = saveAndRedrawWidgets { setWeekStart(pref) }
fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
fun setAgendaWidgetRange(range: AgendaRange) = saveAndRedrawWidgets { setAgendaWidgetRange(range) }
fun setAgendaWidgetRange(range: AgendaRange) = save { setAgendaWidgetRange(range) }
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
/** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */
fun setWidgetSize(size: WidgetSize) = saveAndRedrawWidgets { setWidgetSize(size) }
fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) }
fun setAgendaShowToday(enabled: Boolean) = saveAndRedrawWidgets { setAgendaShowToday(enabled) }
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
fun setTimeFormat(pref: TimeFormatPref) = saveAndRedrawWidgets { setTimeFormat(pref) }
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
fun setMultiDayCount(count: Int) = save { setMultiDayCount(count) }
@@ -335,7 +321,7 @@ class SettingsViewModel @Inject constructor(
}
}
fun setPastEventDisplay(mode: PastEventDisplay) = saveAndRedrawWidgets { setPastEventDisplay(mode) }
fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
@@ -9,7 +9,6 @@ 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
@@ -51,15 +50,12 @@ sealed interface AgendaWidgetData {
data object NeedsPermission : AgendaWidgetData
data class Ready(
val today: LocalDate,
/** Days with events across [range], unfiltered; hiding finished ones happens in the composition. */
/** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
val range: AgendaRange,
val pastDisplay: PastEventDisplay,
/** "Always show today" (#35): anchor today at the top even with no events left. */
val showToday: Boolean,
@@ -73,7 +69,7 @@ sealed interface AgendaWidgetData {
* Source data for the month widget: a window of instances plus the week-start
* preference and today. The widget computes each displayed month's grid from
* this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
* inside [months] is pure recomposition.
* inside the loaded window is pure recomposition.
*/
sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource
@@ -82,8 +78,6 @@ sealed interface MonthWidgetSource {
val weekStart: DayOfWeek,
val soften: Boolean,
val instances: List<EventInstance>,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange,
) : MonthWidgetSource
}
@@ -110,8 +104,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = prefs.softenCalendarColors.first(),
weekStart = weekStart,
range = range,
pastDisplay = prefs.pastEventDisplay.first(),
showToday = prefs.agendaShowToday.first(),
widgetSize = prefs.widgetSize.first(),
@@ -151,6 +143,5 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidge
weekStart = weekStart,
soften = prefs.softenCalendarColors.first(),
instances = ep.calendarRepository().instances(from..to).first(),
months = months,
)
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
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
@@ -15,6 +16,10 @@ import de.jeanlucmakiola.floret.glance.updatePlaced
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -52,26 +57,31 @@ internal suspend fun redrawWidgets(context: Context) {
}
/**
* Redraws the widgets on every calendar change the app itself sees (its own
* writes above all), so they don't wait for `PROVIDER_CHANGED`, which arrives
* seconds late or not at all (#420, #229). Started once from `CalendulaApp`.
* Redraws the widgets on the app's own calendar writes, visibility changes and
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
* which arrives seconds late or not at all (#420, #229). Changes from outside
* the app are [WidgetUpdateReceiver]'s. Started once from `CalendulaApp`.
*/
@Singleton
class WidgetRefreshCoordinator @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: CalendarRepository,
private val prefs: SettingsPrefs,
@ApplicationScope private val scope: CoroutineScope,
) {
private var started = false
fun start() {
if (started) return
started = true
val refresher = WidgetRefresher(scope) {
if (WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) redrawWidgets(context)
}
val refresher = WidgetRefresher(scope) { redrawWidgets(context) }
val settings = listOf(
prefs.softenCalendarColors,
prefs.weekStart,
prefs.timeFormat,
prefs.pastEventDisplay,
prefs.agendaWidgetRange,
prefs.widgetSize,
prefs.agendaShowToday,
).map { flow -> flow.distinctUntilChanged().drop(1).map {} }
scope.launch(Dispatchers.IO) {
repository.changes.collect { refresher.request() }
merge(repository.localChanges, *settings.toTypedArray()).collect { refresher.request() }
}
}
}
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
@@ -59,9 +58,10 @@ import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.redrawWidgets
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.rememberWidgetData
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
@@ -88,13 +88,14 @@ class AgendaWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = context.loadAgendaWidgetData()
val stamp = refreshStamp(context, id)
val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp)
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent {
// A running session never re-runs this preamble, so every redraw
// moves the stamp and the data reloads here instead (#420).
val data by rememberWidgetData(initial, currentRefreshStamp()) { context.loadAgendaWidgetData() }
val data = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidgetData() }
CalendulaGlanceTheme {
AgendaWidgetBody(data = data, dark = dark)
}
@@ -102,10 +103,10 @@ class AgendaWidget : GlanceAppWidget() {
}
}
/** Reloads and redraws the widgets (header refresh button). */
/** Reloads and redraws the tapped widget (header refresh button). */
class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
redrawWidgets(context.applicationContext)
AgendaWidget().updateWithRefresh(context.applicationContext, glanceId)
}
}
@@ -5,6 +5,7 @@ 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
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -39,6 +40,7 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
@@ -63,8 +62,10 @@ import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.rememberWidgetData
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
@@ -127,7 +128,10 @@ class MonthWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
?: currentMonthIndex(systemZone())
val initial = context.loadMonthWidgetSource(monthWindowCenter(shown))
val stamp = refreshStamp(context, id)
val initialCenter = monthWindowCenter(shown)
// Shared by every size Exact composes, so each loads nothing of its own.
val widgetData = WidgetData(context.loadMonthWidgetSource(initialCenter), initialCenter, stamp)
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent {
@@ -135,7 +139,7 @@ class MonthWidget : GlanceAppWidget() {
// Reloads when paging leaves the loaded window, and on every redraw,
// which a running session would otherwise never see (#420).
val center = monthWindowCenter(index)
val source by rememberWidgetData(initial, center, currentRefreshStamp()) {
val source = widgetData.current(center, currentRefreshStamp()) {
context.loadMonthWidgetSource(center)
}
CalendulaGlanceTheme {
@@ -181,11 +185,11 @@ class ShiftMonthAction : ActionCallback {
}
}
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */
class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().update(context.applicationContext, glanceId)
MonthWidget().updateWithRefresh(context.applicationContext, glanceId)
}
}
@@ -5,6 +5,7 @@ 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
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
@@ -41,6 +42,7 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}