diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 16ea6ab..27c551c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -181,6 +181,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
+ implementation("de.jeanlucmakiola.floret:glance")
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b7cf5c5..7dc63c5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -343,12 +343,13 @@
+
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
index 3f9b88c..ac17606 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
@@ -10,7 +10,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
-import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
+import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
+import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -48,6 +49,7 @@ class CalendulaApp : Application() {
reconcileCalendarVisibility()
startReminderDelivery()
reconcileWidgetRollover()
+ widgetEntryPoint().widgetRefreshCoordinator().start()
}
/**
@@ -58,7 +60,7 @@ class CalendulaApp : Application() {
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
- WidgetRolloverScheduler.sync(this@CalendulaApp)
+ syncWidgetRollover(this@CalendulaApp)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
index d140a75..8d946ec 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
@@ -18,6 +18,10 @@ 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
+
suspend fun eventDetail(eventId: Long): EventDetail
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
index 27c5166..2ffa916 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
@@ -87,6 +87,12 @@ class CalendarRepositoryImpl @Inject constructor(
}
}
+ override val changes: Flow = merge(
+ ticks,
+ prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
+ prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
+ )
+
/**
* Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set.
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 cbbe707..c98e092 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
@@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
-import androidx.glance.appwidget.GlanceAppWidgetManager
-import androidx.glance.appwidget.state.updateAppWidgetState
-import androidx.glance.appwidget.updateAll
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -42,7 +39,6 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
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.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
@@ -50,12 +46,7 @@ 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.agenda.AGENDA_PAST_DISPLAY_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
-import de.jeanlucmakiola.calendula.widget.month.MonthWidget
+import de.jeanlucmakiola.calendula.widget.redrawWidgets
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -181,10 +172,17 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow = _fontImportFailed
- // Serialises widget redraws: concurrent updateAll calls coalesce around a
- // stale read and can leave the widget on an intermediate value.
+ // 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 = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates
@@ -267,16 +265,7 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
- fun setSoftenColors(enabled: Boolean) {
- viewModelScope.launch {
- prefs.setSoftenCalendarColors(enabled)
- // Both widgets paint event colours through the same softener.
- widgetRefreshMutex.withLock {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
- }
- }
- }
+ fun setSoftenColors(enabled: Boolean) = saveAndRedrawWidgets { setSoftenCalendarColors(enabled) }
fun setFont(role: FontRole, token: String) = save {
when (role) {
@@ -304,70 +293,20 @@ class SettingsViewModel @Inject constructor(
}
}
- fun setWeekStart(pref: WeekStartPref) {
- viewModelScope.launch {
- prefs.setWeekStart(pref)
- // Both widgets depend on the week start (month weekday header; the
- // agenda widget's "this week" range).
- widgetRefreshMutex.withLock {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
- }
- }
- }
+ fun setWeekStart(pref: WeekStartPref) = saveAndRedrawWidgets { setWeekStart(pref) }
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
- fun setAgendaWidgetRange(range: AgendaRange) {
- viewModelScope.launch {
- prefs.setAgendaWidgetRange(range)
- widgetRefreshMutex.withLock {
- // Push into each instance's Glance state and recompose:
- // updateAll alone won't 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)
- }
- }
- }
+ fun setAgendaWidgetRange(range: AgendaRange) = saveAndRedrawWidgets { setAgendaWidgetRange(range) }
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
- /**
- * Set the size the agenda widget draws its text at (#51), via the same
- * Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
- * sizes itself from the space it is given (#103).
- */
- fun setWidgetSize(size: WidgetSize) {
- viewModelScope.launch {
- prefs.setWidgetSize(size)
- widgetRefreshMutex.withLock {
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
+ /** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */
+ fun setWidgetSize(size: WidgetSize) = saveAndRedrawWidgets { setWidgetSize(size) }
- fun setAgendaShowToday(enabled: Boolean) {
- viewModelScope.launch {
- prefs.setAgendaShowToday(enabled)
- // Same Glance-state path as setPastEventDisplay.
- widgetRefreshMutex.withLock {
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
+ fun setAgendaShowToday(enabled: Boolean) = saveAndRedrawWidgets { setAgendaShowToday(enabled) }
- fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
+ fun setTimeFormat(pref: TimeFormatPref) = saveAndRedrawWidgets { setTimeFormat(pref) }
fun setMultiDayCount(count: Int) = save { setMultiDayCount(count) }
@@ -396,19 +335,7 @@ class SettingsViewModel @Inject constructor(
}
}
- fun setPastEventDisplay(mode: PastEventDisplay) {
- viewModelScope.launch {
- prefs.setPastEventDisplay(mode)
- // Same Glance-state path as setAgendaWidgetRange.
- widgetRefreshMutex.withLock {
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
+ fun setPastEventDisplay(mode: PastEventDisplay) = saveAndRedrawWidgets { setPastEventDisplay(mode) }
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
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 e902b59..259ffba 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
@@ -51,14 +51,7 @@ 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.
- * Kept unfiltered: hiding/dimming finished events also happens reactively
- * in the composition, against [now], for the same reliable-update reason.
- */
+ /** Days with events across [range], unfiltered; hiding finished ones happens in the composition. */
val days: List,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
@@ -66,97 +59,62 @@ sealed interface AgendaWidgetData {
val soften: 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,
- /** Saved past-event display pref — the fallback before Glance state is set. */
- val savedPastDisplay: PastEventDisplay,
- /**
- * Saved "always show today" pref (#35) — anchors today at the top with a
- * placeholder even when it has no events left. The fallback read reactively
- * from Glance state before an instance has its own state set.
- */
- 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,
+ val range: AgendaRange,
+ val pastDisplay: PastEventDisplay,
+ /** "Always show today" (#35): anchor today at the top even with no events left. */
+ val showToday: Boolean,
+ val widgetSize: WidgetSize,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
}
/**
- * Source data for the month widget: a wide 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 month navigation is pure recomposition — no reload, no flaky widget
- * session restart.
+ * 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.
*/
sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource
data class Ready(
val today: LocalDate,
val weekStart: DayOfWeek,
+ val soften: Boolean,
val instances: List,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange,
) : MonthWidgetSource
}
-/**
- * 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).
- */
-internal object MonthWidgetCache {
- @Volatile
- var data: MonthWidgetSource.Ready? = null
-}
-
-internal fun invalidateMonthWidgetCache() {
- MonthWidgetCache.data = null
-}
-
/**
* 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(range: AgendaRange? = null): AgendaWidgetData {
+internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs()
- val savedRange = prefs.agendaWidgetRange.first()
- val savedPastDisplay = prefs.pastEventDisplay.first()
- val showToday = prefs.agendaShowToday.first()
+ val range = prefs.agendaWidgetRange.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
- // 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 dayCount = 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 is24Hour = prefs.timeFormat.first()
- .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
- val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone),
- is24Hour = is24Hour,
- soften = soften,
+ is24Hour = prefs.timeFormat.first()
+ .is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
+ soften = prefs.softenCalendarColors.first(),
weekStart = weekStart,
- savedRange = savedRange,
- savedPastDisplay = savedPastDisplay,
- savedShowToday = showToday,
- savedWidgetSize = prefs.widgetSize.first(),
- loadedThrough = windowEnd,
+ range = range,
+ pastDisplay = prefs.pastEventDisplay.first(),
+ showToday = prefs.agendaShowToday.first(),
+ widgetSize = prefs.widgetSize.first(),
now = Clock.System.now(),
)
}
@@ -165,17 +123,21 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or
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 {
+/**
+ * The month the widget loads its window around while [index] is shown. Snapped
+ * to steps of three, so paging only reloads every third month, and the window
+ * always reaches at least two months past [index] either way.
+ */
+internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
+
+/** Read backing the month widget's grid for the window around [centerIndex]. */
+internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int): 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 && center in it.months) return it }
val ep = widgetEntryPoint()
- val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
- val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
+ val prefs = ep.settingsPrefs()
+ val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
+ val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
// Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first)
.minus(1, DateTimeUnit.WEEK)
@@ -184,11 +146,11 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
.plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59)
.toInstant(zone)
- val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready(
- today = anchor,
+ today = today(zone),
weekStart = weekStart,
- instances = instances,
+ soften = prefs.softenCalendarColors.first(),
+ instances = ep.calendarRepository().instances(from..to).first(),
months = months,
- ).also { MonthWidgetCache.data = it }
+ )
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
index 177576c..e509067 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
interface WidgetEntryPoint {
fun calendarRepository(): CalendarRepository
fun settingsPrefs(): SettingsPrefs
+ fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
}
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
new file mode 100644
index 0000000..360f0f4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
@@ -0,0 +1,77 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.app.PendingIntent
+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.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
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+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,
+)
+
+/** The app's own midnight wake-up (#228). */
+internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
+
+/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
+private const val ROLLOVER_REQUEST_CODE = 0x0DA1
+
+internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ ROLLOVER_REQUEST_CODE,
+ Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+)
+
+/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
+internal fun syncWidgetRollover(context: Context) =
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+
+/**
+ * Reloads and redraws every placed widget, a running session included: the
+ * refresh stamp moves, and both widgets key their data load on it (#420).
+ */
+internal suspend fun redrawWidgets(context: Context) {
+ AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
+ MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
+}
+
+/**
+ * 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`.
+ */
+@Singleton
+class WidgetRefreshCoordinator @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val repository: CalendarRepository,
+ @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)
+ }
+ scope.launch(Dispatchers.IO) {
+ repository.changes.collect { refresher.request() }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt
deleted file mode 100644
index b08c987..0000000
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-package de.jeanlucmakiola.calendula.widget
-
-import android.app.AlarmManager
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import androidx.core.content.getSystemService
-import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
-import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
-import kotlinx.datetime.DateTimeUnit
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.atStartOfDayIn
-import kotlinx.datetime.plus
-import kotlinx.datetime.toLocalDateTime
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.Instant
-
-/**
- * Holds the app's own wake-up for the next local midnight, so the home-screen
- * widgets roll "today" over on the day boundary (#228).
- *
- * The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
- * implicit broadcast — a manifest-declared receiver has not been given it since
- * Android 8, leaving only the throttled `updatePeriodMillis`.
- *
- * Exactly one alarm exists at a time and every firing re-arms the next, the same
- * shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
- * Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
- * survives doze (plain `set` does not), a rollover a few minutes late is
- * invisible on a sleeping screen, and exact alarms stay reserved for snooze.
- */
-object WidgetRolloverScheduler {
-
- /**
- * Fire just *after* midnight: an alarm delivered a few milliseconds early
- * would still read the old date and re-arm for an instant later.
- */
- internal val ROLLOVER_SLACK = 5.seconds
-
- /**
- * Arm the next rollover, or cancel a pending one when no widget is placed.
- * Idempotent, so every trigger (boot, app start, widget added/removed, the
- * rollover itself, a clock or timezone change) can just call it.
- */
- fun sync(context: Context) {
- val appContext = context.applicationContext
- val alarmManager = appContext.getSystemService() ?: return
- val pendingIntent = rolloverPendingIntent(appContext)
- if (!hasPlacedWidgets(appContext)) {
- alarmManager.cancel(pendingIntent)
- return
- }
- val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
- alarmManager.setAndAllowWhileIdle(
- AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
- )
- }
-
- /**
- * The instant just after the next local midnight following [now] in [zone].
- *
- * The *actual* start of day, not 00:00, so it holds where a DST jump means
- * midnight never happens (Havana) and where a date-line move skips a whole
- * local date (Apia, December 2011) — the loop walks on to the next real day.
- *
- * The mirror case — a zone rewinding *across* midnight, so the day starts
- * twice — resolves to the earlier start and runs an hour ahead of the clock.
- * No live tz entry does that (Brazil dropped DST in 2019) and
- * `updatePeriodMillis` covers it, so it isn't worth state to detect.
- */
- fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
- val date = now.toLocalDateTime(zone).date
- var days = 1
- while (days <= MAX_LOOKAHEAD_DAYS) {
- val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
- if (candidate > now) return candidate
- days++
- }
- // Unreachable for any zone in the tz database. Deliberately an hour and
- // not the slack: a 5-second retry would just hit this branch again and
- // wake the device in a loop.
- return now + 1.hours
- }
-
- private fun hasPlacedWidgets(context: Context): Boolean {
- val manager = AppWidgetManager.getInstance(context) ?: return false
- return PROVIDERS.any {
- manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
- }
- }
-
- private fun rolloverPendingIntent(context: Context): PendingIntent =
- PendingIntent.getBroadcast(
- context,
- ROLLOVER_REQUEST_CODE,
- Intent(context, WidgetUpdateReceiver::class.java)
- .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
-
- private val PROVIDERS = listOf(
- MonthWidgetReceiver::class.java,
- AgendaWidgetReceiver::class.java,
- )
-
- /** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
- private const val ROLLOVER_REQUEST_CODE = 0x0DA1
-
- /** A gap of more than a couple of days does not exist in any tz database entry. */
- private const val MAX_LOOKAHEAD_DAYS = 3
-}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
index d679fe5..3fba7de 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
@@ -1,39 +1,25 @@
package de.jeanlucmakiola.calendula.widget
-import android.os.Build
import androidx.compose.runtime.Composable
-import androidx.glance.GlanceTheme
-import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
+import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
/**
- * Brand fallback for devices without Material You dynamic colour (API < 31).
- * Reuses the exact same hand-tuned schemes as the in-app theme
- * ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
- * device matches the app surface-for-surface.
- */
-private val CalendulaGlanceColors = ColorProviders(
- light = CalendulaLightFallback,
- dark = CalendulaDarkFallback,
-)
-
-/**
- * Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
- * Material You palette (so the widget matches the home screen / the app's
- * dynamic colour); below that it falls back to the brand scheme. Either way the
- * widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
- * a hardcoded colour — so it tracks light/dark automatically.
+ * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
+ * hand-tuned fallback schemes below that, following the system's light/dark.
*/
@Composable
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
- val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- GlanceTheme.colors
- } else {
- CalendulaGlanceColors
- }
- GlanceTheme(colors = colors, content = content)
+ FloretGlanceTheme(
+ lightScheme = CalendulaLightFallback,
+ darkScheme = CalendulaDarkFallback,
+ dynamicColor = true,
+ mode = WidgetThemeMode.SYSTEM,
+ content = content,
+ )
}
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
index b4972aa..0182171 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
@@ -1,120 +1,26 @@
package de.jeanlucmakiola.calendula.widget
-import android.content.BroadcastReceiver
+import android.app.PendingIntent
import android.content.Context
import android.content.Intent
-import android.os.SystemClock
-import androidx.glance.appwidget.updateAll
-import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
-import de.jeanlucmakiola.calendula.widget.month.MonthWidget
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
-import java.util.concurrent.atomic.AtomicLong
+import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
+import kotlin.time.Duration.Companion.minutes
/**
- * Redraws both home-screen widgets when their data goes stale, and keeps the
- * midnight rollover alarm armed. Triggered by:
- * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
- * so it covers both the app's own writes and external sync.
- * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
- * the day boundary, so "today" highlighting and the agenda's past-event
- * dimming move on (#228).
- * - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
- * re-arm.
- * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
- * latter is also what arms installs upgrading into the fix.
- *
- * `DATE_CHANGED` rides along as a free extra the system may or may not send —
- * see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
- * provider XML and the month widget's refresh button.
- *
- * Every action that dropped, consumed or invalidated the alarm re-arms it, and
- * `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
- * ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
- * a restricted standby bucket; nothing in ordinary daily use would then notice,
- * and the widget sits on yesterday's date. A calendar change is the one signal
- * that keeps arriving — but it arrives on *every* write (each drag, each event
- * of a thousand-event import), which is what the interval is for.
- *
- * Exported for the system broadcasts; an extra redraw from another app is
- * harmless.
+ * Redraws both widgets when their data, day or language goes stale, and keeps
+ * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
+ * the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
+ * change also re-arms, at most every 15 minutes: the inexact alarm can be
+ * dropped in a restricted standby bucket, and a calendar change is the one
+ * signal that keeps arriving.
*/
-class WidgetUpdateReceiver : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- // Exported, so anything can reach it with an explicit intent. Nothing
- // here crosses a trust boundary, but narrowing to the actions we asked
- // for keeps a stray broadcast from costing two wide provider reads.
- if (intent.action !in HANDLED_ACTIONS) return
- val appContext = context.applicationContext
- val rearm = intent.action in REARM_ACTIONS ||
- (intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
- // The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
- // redrawing there would only repeat the work in a cold process, at the
- // moment the device is most contended.
- val redraw = intent.action !in REARM_ONLY_ACTIONS
- val pending = goAsync()
- // Calendar data may have changed (sync / our own write) — drop the cached
- // month window so the widgets reload fresh. Month paging does NOT call
- // this, so arrow taps stay instant.
- if (redraw) invalidateMonthWidgetCache()
- CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
- try {
- // Re-armed first, so the next day boundary is covered whatever
- // the redraw does — and off the receiving thread, since it is a
- // handful of binder calls (`CalendulaApp` moves it for the same
- // reason).
- if (rearm) WidgetRolloverScheduler.sync(appContext)
- if (redraw) {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
- }
- } finally {
- pending.finish()
- }
- }
- }
+class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
+ override val rolloverAction = ACTION_WIDGET_ROLLOVER
+ override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
+ override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
+ override val extraRearmInterval = 15.minutes
- companion object {
- /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
- const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
+ override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
- /** Both wipe pending alarms, and the host redraws the widgets itself after them. */
- private val REARM_ONLY_ACTIONS = setOf(
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- )
-
- /**
- * The actions that leave no alarm standing: the two above wipe it, the
- * rollover consumed itself, and a clock, zone or day change moved the
- * boundary the pending one was set for.
- */
- internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
- ACTION_ROLLOVER,
- Intent.ACTION_TIME_CHANGED,
- Intent.ACTION_TIMEZONE_CHANGED,
- Intent.ACTION_DATE_CHANGED,
- )
-
- internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
-
- /** How rarely a calendar change may re-arm; see the class doc. */
- private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
-
- /** Uptime of the last data-change re-arm, 0 for none in this process. */
- private val lastDataRearmMillis = AtomicLong(0L)
-
- /**
- * Whether a calendar change may re-arm now — the backstop for an alarm
- * the system dropped, throttled so a burst of writes costs one pass.
- */
- private fun dataRearmDue(): Boolean {
- val now = SystemClock.elapsedRealtime()
- val last = lastDataRearmMillis.get()
- val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
- return due && lastDataRearmMillis.compareAndSet(last, now)
- }
- }
+ override suspend fun redraw(context: Context) = redrawWidgets(context)
}
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 548572c..cb98ecb 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,16 +5,9 @@ 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
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
@@ -33,8 +26,6 @@ import androidx.glance.appwidget.lazy.LazyColumn
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
@@ -53,15 +44,11 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
-import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
-import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
-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.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
@@ -72,6 +59,9 @@ 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.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.rememberWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
@@ -82,40 +72,6 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
-/**
- * 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")
-
-/**
- * Per-instance Glance state key holding the past-event display mode (as
- * [PastEventDisplay.name]). Read reactively in the composition for the same
- * reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
- * widget without depending on the `provideGlance` preamble re-running.
- */
-internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
-
-/**
- * Per-instance Glance state key holding the "always show today" toggle (#35).
- * Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
- * so toggling the setting reflects on the live widget without depending on the
- * `provideGlance` preamble re-running.
- */
-internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
-
-/**
- * Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
- * in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
- * setting reflects on the live widget without depending on the `provideGlance`
- * preamble re-running.
- */
-internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
-
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events grouped under day headers (the Google "Schedule" widget model).
@@ -132,11 +88,13 @@ class AgendaWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
- val stored = getAppWidgetState(context, id)[AGENDA_RANGE_KEY]
- val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
+ val initial = context.loadAgendaWidgetData()
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() }
CalendulaGlanceTheme {
AgendaWidgetBody(data = data, dark = dark)
}
@@ -144,16 +102,10 @@ class AgendaWidget : GlanceAppWidget() {
}
}
-/**
- * Redraws the widget (header refresh button). Targets the tapped widget's own id
- * rather than `updateAll`, whose provider-name lookup is empty in a process a tap
- * woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
- * calendar in the `provideGlance` preamble; a live session only recomposes from
- * the snapshot it already has (see [AGENDA_RANGE_KEY]).
- */
+/** Reloads and redraws the widgets (header refresh button). */
class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
- AgendaWidget().update(context.applicationContext, glanceId)
+ redrawWidgets(context.applicationContext)
}
}
@@ -176,14 +128,9 @@ private sealed interface AgendaRow {
@Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
- // Read reactively from per-instance Glance state, falling back to the saved
- // pref for a freshly placed widget (#103, #51).
- // The stripe is then re-resolved against the system font scale so it tracks the
+ // The stripe is re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
- val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL
- val size = currentState(AGENDA_SIZE_KEY)
- ?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
- ?: savedSize
+ val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale)
Column(
@@ -196,27 +143,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
Spacer(GlanceModifier.height(4.dp))
when (data) {
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 loaded
- // window is sliced to it — pure recomposition.
- val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
- 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)
- }
+ is AgendaWidgetData.Ready -> AgendaRows(data = data, dark = dark, metrics = metrics)
}
}
}
@@ -224,24 +151,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
@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 pastDisplay = data.pastDisplay
+ // Drop finished events (and any day they leave empty) when the mode is
+ // Hide — so "Upcoming" really is. Then 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 ->
@@ -253,7 +172,7 @@ private fun AgendaRows(
}
},
today = data.today,
- enabled = showToday,
+ enabled = data.showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics)
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 736da00..294cbd8 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
@@ -4,7 +4,7 @@ import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
+import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -17,16 +17,16 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
- WidgetRolloverScheduler.sync(context)
+ syncWidgetRollover(context)
}
/**
- * Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
+ * Last agenda widget removed. [syncWidgetRollover] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
- WidgetRolloverScheduler.sync(context)
+ syncWidgetRollover(context)
}
/**
@@ -39,6 +39,6 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
- WidgetRolloverScheduler.sync(context)
+ syncWidgetRollover(context)
}
}
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 c72e851..d5d2b88 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,11 +5,7 @@ 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
@@ -66,11 +62,12 @@ 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.monthWindowCenter
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.rememberWidgetData
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
-import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
-import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -129,15 +126,20 @@ class MonthWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY]
- val source = context.loadMonthWidgetSource(shown)
+ ?: currentMonthIndex(systemZone())
+ val initial = context.loadMonthWidgetSource(monthWindowCenter(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
- // redraws with the new choice; it's one cheap DataStore read.
- val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
provideContent {
+ val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone())
+ // 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()) {
+ context.loadMonthWidgetSource(center)
+ }
CalendulaGlanceTheme {
- MonthWidgetBody(source = source, dark = dark, soften = soften)
+ MonthWidgetBody(source = source, index = index, dark = dark)
}
}
}
@@ -188,7 +190,7 @@ class ResetMonthAction : ActionCallback {
}
@Composable
-private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
+private fun MonthWidgetBody(source: MonthWidgetSource, index: Int, dark: Boolean) {
Column(
modifier = GlanceModifier
.fillMaxSize()
@@ -202,32 +204,21 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
}
is MonthWidgetSource.Ready -> {
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, loaded.weekStart, loaded.instances, zone)
+ val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
- MonthHeader(label = monthLabel(ym, loaded.today.year))
+ MonthHeader(label = monthLabel(ym, source.today.year))
Spacer(GlanceModifier.height(2.dp))
- WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
+ WeekdayHeader(weekStart = source.weekStart, colW = colW)
weeks.forEach { week ->
WeekRow(
week = week,
currentMonth = ym.month,
- today = loaded.today,
+ today = source.today,
dark = dark,
- soften = soften,
+ soften = source.soften,
colW = colW,
modifier = GlanceModifier.defaultWeight(),
)
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 08e4a3e..82830fa 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
@@ -4,7 +4,7 @@ import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
+import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
@@ -16,16 +16,16 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
- WidgetRolloverScheduler.sync(context)
+ syncWidgetRollover(context)
}
/**
- * Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
+ * Last month widget removed. [syncWidgetRollover] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
- WidgetRolloverScheduler.sync(context)
+ syncWidgetRollover(context)
}
/**
@@ -41,6 +41,6 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
- WidgetRolloverScheduler.sync(context)
+ syncWidgetRollover(context)
}
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt
new file mode 100644
index 0000000..edcad5c
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt
@@ -0,0 +1,23 @@
+package de.jeanlucmakiola.calendula.widget
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class MonthWindowCenterTest {
+
+ @Test
+ fun `the loaded window always reaches two months past the shown one`() {
+ for (index in -40..40) {
+ val center = monthWindowCenter(index)
+ assertThat(index - 2).isAtLeast(center - 3)
+ assertThat(index + 2).isAtMost(center + 3)
+ }
+ }
+
+ @Test
+ fun `paging within a step of three keeps the same window`() {
+ val year = 2026 * 12
+ assertThat(monthWindowCenter(year)).isEqualTo(monthWindowCenter(year + 2))
+ assertThat(monthWindowCenter(year + 3)).isNotEqualTo(monthWindowCenter(year + 2))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt
deleted file mode 100644
index 8242073..0000000
--- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-package de.jeanlucmakiola.calendula.widget
-
-import android.content.Intent
-import com.google.common.truth.Truth.assertThat
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.atStartOfDayIn
-import kotlinx.datetime.toInstant
-import kotlinx.datetime.toLocalDateTime
-import org.junit.jupiter.api.Test
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.minutes
-import kotlin.time.Instant
-
-/**
- * The rollover alarm is what makes the widgets stop highlighting yesterday
- * (#228), so the "when is the next local midnight" arithmetic is the one piece
- * worth pinning down — especially where midnight is not 00:00.
- */
-class WidgetRolloverSchedulerTest {
-
- private val berlin = TimeZone.of("Europe/Berlin")
-
- private fun at(local: String, zone: TimeZone): Instant =
- LocalDateTime.parse(local).toInstant(zone)
-
- private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
- WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
-
- // --- the ordinary day ----------------------------------------------------
-
- @Test
- fun `midday rolls over at the coming midnight`() {
- val next = nextRollover("2026-08-27T12:00:00")
- assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
- }
-
- @Test
- fun `a second before midnight still targets tonight, not tomorrow night`() {
- val next = nextRollover("2026-08-27T23:59:59")
- assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
- }
-
- @Test
- fun `at midnight exactly the target is the next day, never the current instant`() {
- // Re-arming after a firing must move a whole day on, or the widget wakes
- // itself in a tight loop.
- val now = at("2026-08-28T00:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
- assertThat(next - now).isGreaterThan(Duration.ZERO)
- }
-
- @Test
- fun `re-arming from the slack instant itself moves a full day on`() {
- // What actually happens in practice: the receiver runs at midnight + slack.
- val now = at("2026-08-28T00:00:05", berlin)
- assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
- .isEqualTo(at("2026-08-29T00:00:05", berlin))
- }
-
- @Test
- fun `the result is always in the future for every minute of a day`() {
- val zone = berlin
- // Spans Berlin's 2024 spring-forward: the likeliest source of a target
- // in the past, i.e. an alarm that fires immediately, forever.
- var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
- val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
- while (probe < end) {
- assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
- probe += 1.minutes
- }
- }
-
- // --- daylight saving -----------------------------------------------------
-
- @Test
- fun `spring forward keeps the rollover one day away, not one hour short`() {
- // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
- // A rollover computed as "now + 24h" would land at 01:00 on 1 April.
- val now = at("2024-03-30T12:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
- assertThat(next - now).isLessThan(24.hours)
- }
-
- @Test
- fun `fall back does not overshoot into the repeated hour`() {
- // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
- // "now + 24h" would land at 23:00 on the 26th and never roll over.
- val now = at("2024-10-26T12:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
- assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
- }
-
- @Test
- fun `a zone that repeats midnight takes the first start of day`() {
- // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
- // began twice. Pinned as the accepted trade: the widget runs an hour
- // ahead until the next redraw. No live zone does this since 2019.
- val saoPaulo = TimeZone.of("America/Sao_Paulo")
- val next = WidgetRolloverScheduler.nextRolloverAt(
- at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
- )
- val local = next.toLocalDateTime(saoPaulo)
- assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
- assertThat(local.hour).isEqualTo(0)
- }
-
- @Test
- fun `a zone where midnight does not exist rolls over at the real start of day`() {
- // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
- // Targeting a literal 00:00 there would arm an instant on the wrong day.
- val havana = TimeZone.of("America/Havana")
- val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
- val local = next.toLocalDateTime(havana)
- assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
- assertThat(local.hour).isEqualTo(1)
- assertThat(local.minute).isEqualTo(0)
- // And it is genuinely the first instant of that date, not a guess.
- assertThat(next).isEqualTo(
- LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
- WidgetRolloverScheduler.ROLLOVER_SLACK,
- )
- }
-
- // --- timezone changes ----------------------------------------------------
-
- @Test
- fun `the same instant rolls over at different times in different zones`() {
- // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
- // follows the zone, not a cached offset.
- val instant = at("2026-08-27T12:00:00", berlin)
- val tokyo = TimeZone.of("Asia/Tokyo")
- val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
- val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
- assertThat(tokyoNext).isNotEqualTo(berlinNext)
- assertThat(tokyoNext).isLessThan(berlinNext)
- assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
- }
-
- @Test
- fun `a half-hour offset zone still lands on its own midnight`() {
- val kathmandu = TimeZone.of("Asia/Kathmandu")
- val next = WidgetRolloverScheduler.nextRolloverAt(
- at("2026-08-27T12:00:00", kathmandu), kathmandu,
- )
- val local = next.toLocalDateTime(kathmandu)
- assertThat(local.date.toString()).isEqualTo("2026-08-28")
- assertThat(local.hour).isEqualTo(0)
- }
-
- // --- the wiring ----------------------------------------------------------
-
- @Test
- fun `the receiver actually handles the action the alarm is sent with`() {
- // The single point where the whole fix would die silently: the alarm
- // fires, the receiver drops it on the action guard, nothing redraws.
- assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
- .contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
- }
-
- @Test
- fun `a data change is handled, and re-arms only on the throttle`() {
- // PROVIDER_CHANGED fires on every calendar write — each event of a bulk
- // import, each drop of a drag — so it is not an unconditional re-arm.
- // It is not dropped either: the alarm is inexact and the system may lose
- // it, and this is the only signal that keeps arriving to notice.
- assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
- .containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
- assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
- .doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
- }
-
- @Test
- fun `everything that loses the alarm re-arms it`() {
- assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
- WidgetUpdateReceiver.ACTION_ROLLOVER,
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- Intent.ACTION_TIME_CHANGED,
- Intent.ACTION_TIMEZONE_CHANGED,
- Intent.ACTION_DATE_CHANGED,
- )
- }
-}
diff --git a/floret-kit b/floret-kit
index 9f90027..acc983f 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit 9f90027dcd9f1f7a0511bea462ecdf297387b912
+Subproject commit acc983f2ac4ca73769e28bc0d71da56293f42b2f