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