Compare commits

...
19 changed files with 254 additions and 751 deletions
+1
View File
@@ -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)
+8 -6
View File
@@ -343,12 +343,13 @@
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync).
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
filter here; DATE_CHANGED is a free extra only, since Android 8+
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
move the boundary, boot / package-replace wipe the alarm, and
LOCALE_CHANGED redraws the labels in the new language.
Exported: the system broadcasts arrive from outside the app. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
@@ -365,6 +366,7 @@
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
@@ -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)
}
}
@@ -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<Unit>
suspend fun eventDetail(eventId: Long): EventDetail
/**
@@ -87,6 +87,12 @@ class CalendarRepositoryImpl @Inject constructor(
}
}
override val changes: Flow<Unit> = 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.
@@ -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<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _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<SpecialDatesUiState> = 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) }
@@ -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<AgendaDay>,
/** 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<EventInstance>,
/** 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 }
)
}
@@ -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 =
@@ -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<Class<*>> = 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() }
}
}
}
@@ -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<AlarmManager>() ?: 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
}
@@ -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,
)
}
/**
@@ -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)
}
@@ -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<Preferences>(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<AgendaWidgetData.Ready>(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)
@@ -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)
}
}
@@ -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<Preferences>(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<MonthWidgetSource.Ready>(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(),
)
@@ -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)
}
}
@@ -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))
}
}
@@ -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,
)
}
}