diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 71929e5..24e5c3e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -426,7 +426,7 @@
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
index 18e5c24..182a469 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
@@ -595,7 +595,9 @@ class MainActivity : AppCompatActivity() {
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply {
- data = "calendula://date/$date".toUri()
+ // The source is part of the URI so widgets of different kinds
+ // tapping the same date don't share one PendingIntent.
+ data = "calendula://date/$date/${source.name}".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -614,6 +616,7 @@ class MainActivity : AppCompatActivity() {
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
+ data = "calendula://event/$eventId/$beginMillis/${source.name}".toUri()
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
index 62f7670..de1254b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
@@ -571,7 +571,14 @@ class AndroidCalendarDataSource @Inject constructor(
queryInstances(beginMillis, endMillis, null, null)
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List =
- queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
+ // The series' own rows plus its exceptions, so an occurrence edited on
+ // its own is still one of the series' occurrences.
+ queryInstances(
+ beginMillis,
+ endMillis,
+ "(${CalendarContract.Instances.EVENT_ID} = ? OR ${CalendarContract.Instances.ORIGINAL_ID} = ?)",
+ arrayOf(eventId.toString(), eventId.toString()),
+ )
private fun queryInstances(
beginMillis: Long,
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 e0fa731..3d1308c 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
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
import kotlin.time.Instant
interface CalendarRepository {
@@ -108,6 +109,12 @@ interface CalendarRepository {
form: EventForm,
): Long
+ /**
+ * Every series split the app makes ([updateEventFromOccurrence]), so what
+ * follows a series by id can move on to the part that continues it.
+ */
+ val seriesSplits: Flow get() = emptyFlow()
+
/**
* Change a recurring event from [beginMillis] onwards (series split);
* returns the new event's `Events._ID`.
@@ -135,3 +142,6 @@ class NoSuchEventException(eventId: Long) :
/** A ContentResolver write affected no rows or returned no URI. */
class WriteFailedException(operation: String) :
RuntimeException("Calendar write failed: $operation")
+
+/** A "this and following" edit: [oldEventId] now ends where [newEventId] picks up. */
+data class SeriesSplit(val oldEventId: Long, val newEventId: Long)
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 537fc48..481ae7a 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
@@ -351,7 +351,11 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
- }
+ }.also { newId -> if (newId != eventId) splits.tryEmit(SeriesSplit(eventId, newId)) }
+
+ private val splits = MutableSharedFlow(extraBufferCapacity = 8)
+
+ override val seriesSplits: Flow = splits
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
dataSource.deleteOccurrence(eventId, beginMillis)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt
index 0dad2ed..9ea1a91 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
@@ -100,10 +101,16 @@ val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
- * is *not* considered ended. All-day events end at the exclusive next-midnight,
- * so they only count as ended once their day is fully over.
+ * is *not* considered ended. All-day events end at the exclusive next-midnight
+ * in [zone] rather than the UTC midnight they are stored on, so they only count
+ * as ended once their local day is fully over.
*/
-fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
+fun EventInstance.hasEnded(now: Instant, zone: TimeZone = TimeZone.currentSystemDefault()): Boolean =
+ localEnd(zone) <= now
+
+/** [EventInstance.end], with an all-day event's UTC midnight moved to local midnight in [zone]. */
+fun EventInstance.localEnd(zone: TimeZone): Instant =
+ if (isAllDay) end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone) else end
/**
* The zone this event's calendar dates live in: the device [zone] for timed
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt
index e827a1a..14d8de8 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt
@@ -30,7 +30,7 @@ fun EventInstance.isPastOn(
day: LocalDate,
now: Instant,
zone: TimeZone = TimeZone.currentSystemDefault(),
-): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
+): Boolean = hasEnded(now, zone) || day < now.toLocalDateTime(zone).date
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt
index dbc12e0..db9c78e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt
@@ -27,6 +27,14 @@ abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
syncWidgetRollover(context)
}
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ super.onDeleted(context, appWidgetIds)
+ evictMonthSources(appWidgetIds)
+ WidgetKind.entries.firstOrNull { it.receiver == this::class.java }?.let { kind ->
+ appWidgetIds.forEach { WidgetTicks.cancel(context, kind, it) }
+ }
+ }
+
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
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 cc99c55..86be0e9 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.glance.GlanceId
+import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -170,7 +171,7 @@ internal suspend fun Context.loadAgendaWidgetData(
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
- val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
+ val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
val dayCount = options.range.dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
@@ -189,7 +190,8 @@ internal suspend fun Context.loadAgendaWidgetData(
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
-private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
+internal fun firstDayOfMonthIndex(index: Int): LocalDate =
+ LocalDate(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1, 1)
/**
* The month the widget loads its window around while [index] is shown. Snapped
@@ -207,28 +209,50 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 +
private object MonthSourceCache {
private class Entry(val center: Int, val stamp: Long, val source: Themed)
- private val entries = ConcurrentHashMap()
+ // Keyed by app-widget id, which a removed widget can still be named by.
+ private val entries = ConcurrentHashMap()
- fun get(id: GlanceId, center: Int, stamp: Long): Themed? =
+ fun get(id: Int, center: Int, stamp: Long): Themed? =
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
?.source
- fun put(id: GlanceId, center: Int, stamp: Long, source: Themed) {
+ fun put(id: Int, center: Int, stamp: Long, source: Themed) {
val ready = source.content as? MonthWidgetSource.Ready ?: return
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
}
+
+ fun remove(id: Int) {
+ entries.remove(id)
+ }
}
-/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
-internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed =
- MonthSourceCache.get(id, centerIndex, stamp)
- ?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
- .also { MonthSourceCache.put(id, centerIndex, stamp, it) }
+/** Drops the cached windows of removed widgets [appWidgetIds]. */
+internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove)
+
+/**
+ * [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds.
+ * A [load] already read for this pass saves reading the settings and widget state again.
+ */
+internal suspend fun Context.loadCachedMonthSource(
+ id: GlanceId,
+ centerIndex: Int,
+ stamp: Long,
+ load: WidgetLoadContext? = null,
+): Themed {
+ val key = GlanceAppWidgetManager(this).getAppWidgetId(id)
+ return MonthSourceCache.get(key, centerIndex, stamp)
+ ?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings)
+ .also { MonthSourceCache.put(key, centerIndex, stamp, it) }
+}
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
-internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set): Themed {
- val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
- return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
+internal suspend fun Context.loadThemedMonthSource(
+ centerIndex: Int,
+ hiddenCalendars: Set,
+ settings: WidgetSettings? = null,
+): Themed {
+ val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.themeMode)
}
private suspend fun Context.queryMonthSource(
@@ -250,7 +274,7 @@ private suspend fun Context.queryMonthSource(
.toInstant(zone)
return MonthWidgetSource.Ready(
today = today(zone),
- weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
+ weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
months = months,
@@ -298,7 +322,7 @@ internal suspend fun Context.loadDays(
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
val zone = systemZone()
val anchor = today(zone)
- val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
+ val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
val range = days(anchor, weekStart)
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt
index 41755f4..1fb5ac2 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt
@@ -95,6 +95,9 @@ enum class WidgetKind(
),
;
+ /** Whether a calendar change can change what it draws; Date and Quick add draw no events. */
+ val drawsEvents: Boolean get() = this != QUICK_ADD && this != DATE
+
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt
index e16193b..9022f75 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt
@@ -18,7 +18,6 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
-import java.util.Locale
import kotlin.time.Instant
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@@ -39,15 +38,20 @@ internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalD
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(systemZone()).time
- return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
+ return formatTimeOfDay(t.hour, t.minute, is24Hour, widgetLocale())
+}
+
+/** "Today" or "Tomorrow" for those two days, else null. */
+internal fun relativeDayWord(context: Context, date: LocalDate, today: LocalDate): String? = when (date) {
+ today -> context.getString(R.string.agenda_header_today)
+ today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
+ else -> null
}
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
- val locale = Locale.getDefault()
- return when {
- date == today -> context.getString(R.string.agenda_header_today)
- date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
+ val locale = widgetLocale()
+ return relativeDayWord(context, date, today) ?: when {
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt
index b2a4788..db6603c 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt
@@ -40,15 +40,29 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
-import java.util.Locale
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
-/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
+/**
+ * Republishes the generated picker previews (Android 15+) when anything they
+ * draw with has changed: the app version, language, today's date, or the
+ * theme, week start, time format and colour softening.
+ */
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
+ val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ val updated = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
+ val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
publishWidgetPreviews(
context,
- version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
+ version = listOf(
+ updated,
+ widgetLocale().toLanguageTag(),
+ today(systemZone()),
+ settings.themeMode,
+ settings.weekStart,
+ is24Hour,
+ settings.soften,
+ ).joinToString("-"),
receivers = listOf(
AgendaWidgetReceiver::class,
MonthWidgetReceiver::class,
@@ -73,7 +87,7 @@ internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind
val today = today(zone)
val now = today.atTime(8, 0).toInstant(zone)
val instances = context.sampleInstances(today, zone)
- val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
+ val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val options = settings.agendaDefaults
val appearance = WidgetAppearance()
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
index 7182363..c17fcae 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
@@ -6,18 +6,22 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.SystemClock
+import android.util.Log
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.widget.countdown.repointCountdowns
import de.jeanlucmakiola.floret.glance.WidgetRefresher
import de.jeanlucmakiola.floret.glance.WidgetRollover
import de.jeanlucmakiola.floret.glance.updatePlaced
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import androidx.glance.appwidget.GlanceAppWidgetManager
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -38,13 +42,16 @@ internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.
internal const val ACTION_WIDGET_REDRAW = "de.jeanlucmakiola.calendula.widget.REDRAW"
/**
- * Asks [WidgetUpdateReceiver] to redraw every widget. A broadcast rather than a
+ * Asks [WidgetRedrawReceiver] to redraw every widget. A broadcast rather than a
* coroutine, so it outlives the activity a language change recreates.
*/
fun requestWidgetRedraw(context: Context) {
- context.sendBroadcast(Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_REDRAW))
+ context.sendBroadcast(Intent(context, WidgetRedrawReceiver::class.java).setAction(ACTION_WIDGET_REDRAW))
}
+/** The widgets a calendar change can affect. */
+internal val EVENT_WIDGET_KINDS: List = WidgetKind.entries.filter { it.drawsEvents }
+
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
@@ -63,10 +70,22 @@ internal fun syncWidgetRollover(context: Context) =
* Reloads and redraws every placed widget, a running session included: the
* refresh stamp moves, and every widget keys its data load on it (#420).
*/
-internal suspend fun redrawWidgets(context: Context) {
- WidgetKind.entries.forEach { redrawWidgets(context, it) }
+internal suspend fun redrawWidgets(context: Context, kinds: List = WidgetKind.entries) {
+ // One kind failing, say a widget removed mid-pass, mustn't stop the others
+ // or escape the broadcast's coroutine.
+ kinds.forEach { kind ->
+ try {
+ redrawWidgets(context, kind)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Redrawing $kind widgets failed", e)
+ }
+ }
}
+private const val TAG = "WidgetRedraw"
+
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
@@ -77,8 +96,14 @@ private suspend fun redrawUnconfiguredWidgets(context: Context) {
val glanceManager = GlanceAppWidgetManager(appContext)
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
- val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
- if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
+ try {
+ val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
+ if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Redrawing widget $appWidgetId failed", e)
+ }
}
}
}
@@ -88,9 +113,32 @@ private val lastLocalChange = AtomicLong(0L)
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
-internal fun isProviderEcho(): Boolean {
+/** Set while a `PROVIDER_CHANGED` redraw waits out the echo window, so a burst of them redraws once. */
+private val echoRedrawQueued = AtomicBoolean(false)
+
+private fun providerEchoRemainingMillis(): Long {
val last = lastLocalChange.get()
- return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
+ if (last == 0L) return 0L
+ return PROVIDER_ECHO_WINDOW_MILLIS - (SystemClock.elapsedRealtime() - last)
+}
+
+/**
+ * Whether a `PROVIDER_CHANGED` should redraw. Within the echo window of one of
+ * the app's own changes it may only echo that change, or be a sync landing
+ * right after it, so it isn't dropped: the first one redraws once the window
+ * has passed ([awaitProviderEcho]) and the rest of the burst ride along.
+ */
+internal fun claimProviderRedraw(): Boolean =
+ providerEchoRemainingMillis() <= 0L || echoRedrawQueued.compareAndSet(false, true)
+
+/** Waits out the echo window, if one is open, before a `PROVIDER_CHANGED` redraw. */
+internal suspend fun awaitProviderEcho() {
+ try {
+ val wait = providerEchoRemainingMillis()
+ if (wait > 0L) delay(wait)
+ } finally {
+ echoRedrawQueued.set(false)
+ }
}
/**
@@ -113,23 +161,28 @@ class WidgetRefreshCoordinator @Inject constructor(
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
fun start() {
if (!started.compareAndSet(false, true)) return
- val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
+ val refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
+ val all = refreshers.values
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
watch(repository.localChanges) {
lastLocalChange.set(SystemClock.elapsedRealtime())
- all.forEach { it.request() }
+ EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() }
+ }
+ watch(repository.seriesSplits) { split ->
+ scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) }
}
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
all.forEach { it.request() }
+ scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
}
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
unconfigured.request()
}
}
- private fun watch(changes: Flow<*>, onChange: () -> Unit) {
+ private fun watch(changes: Flow, onChange: (T) -> Unit) {
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
- changes.collect { onChange() }
+ changes.collect { onChange(it) }
}
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedrawReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedrawReceiver.kt
new file mode 100644
index 0000000..d097541
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedrawReceiver.kt
@@ -0,0 +1,22 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import de.jeanlucmakiola.floret.android.goAsync
+import de.jeanlucmakiola.floret.locale.AppLanguage
+
+/**
+ * Takes [requestWidgetRedraw]'s in-app redraw. Not exported, unlike
+ * [WidgetUpdateReceiver], so no other app can make every widget reload.
+ */
+class WidgetRedrawReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_WIDGET_REDRAW) return
+ val appContext = context.applicationContext
+ goAsync {
+ AppLanguage.applyDefault(appContext)
+ redrawWidgets(appContext)
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTicks.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTicks.kt
new file mode 100644
index 0000000..6638d3f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTicks.kt
@@ -0,0 +1,79 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.net.toUri
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import de.jeanlucmakiola.floret.android.goAsync
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import kotlinx.coroutines.CancellationException
+import kotlin.time.Instant
+
+/**
+ * One alarm per placed widget, for the moment what it shows next changes: a
+ * countdown's label, the Today list's last event ending. Not a wake-up alarm:
+ * with the screen off nobody sees the widget, so the redraw waits for the
+ * device to wake. Exact where allowed (the app holds the exact alarm
+ * permission for reminders), else within a minute.
+ */
+internal object WidgetTicks {
+ private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.TICK"
+ internal const val EXTRA_KIND = "de.jeanlucmakiola.calendula.widget.KIND"
+
+ fun schedule(context: Context, kind: WidgetKind, appWidgetId: Int, at: Instant) {
+ val alarms = context.getSystemService(AlarmManager::class.java) ?: return
+ val intent = pendingIntent(context, kind, appWidgetId)
+ val millis = at.toEpochMilliseconds()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
+ alarms.setExact(AlarmManager.RTC, millis, intent)
+ } else {
+ alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
+ }
+ }
+
+ fun cancel(context: Context, kind: WidgetKind, appWidgetId: Int) {
+ context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, kind, appWidgetId))
+ }
+
+ private fun pendingIntent(context: Context, kind: WidgetKind, appWidgetId: Int): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(context, WidgetTickReceiver::class.java)
+ .setAction(ACTION_TICK)
+ // Keys the PendingIntent per widget.
+ .setData("calendula://tick/${kind.name}/$appWidgetId".toUri())
+ .putExtra(EXTRA_KIND, kind.name)
+ .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
+
+/** Reloads the widget whose content just changed; the load arms its next tick. */
+class WidgetTickReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ val kind = intent.getStringExtra(WidgetTicks.EXTRA_KIND)
+ ?.let { name -> WidgetKind.entries.firstOrNull { it.name == name } }
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID || kind == null) return
+ val appContext = context.applicationContext
+ goAsync {
+ try {
+ val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
+ kind.widget().updateWithRefresh(appContext, glanceId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: IllegalArgumentException) {
+ // The widget is gone.
+ WidgetTicks.cancel(appContext, kind, appWidgetId)
+ } catch (_: Exception) {
+ // A failed redraw leaves the last frame; the next signal retries.
+ }
+ }
+ }
+}
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 f92de6f..6f615dd 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
@@ -10,24 +10,33 @@ import kotlin.time.Duration.Companion.minutes
/**
* Redraws the 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. A `PROVIDER_CHANGED` right after one of the
- * app's own changes only echoes it, so it doesn't redraw a second time.
+ * the app, and only redraws the widgets that draw events; 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. A
+ * `PROVIDER_CHANGED` right after one of the app's own changes waits out the
+ * echo window and redraws once ([claimProviderRedraw]).
*/
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override val rolloverAction = ACTION_WIDGET_ROLLOVER
override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
- override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED, ACTION_WIDGET_REDRAW)
+ override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
override val extraRearmInterval = 15.minutes
+ /** This broadcast's action; a receiver instance handles exactly one. */
+ private var action: String? = null
+
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
- override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
+ override fun skipRedraw(action: String): Boolean {
+ this.action = action
+ return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw()
+ }
override suspend fun redraw(context: Context) {
+ val dataChange = action == Intent.ACTION_PROVIDER_CHANGED
+ if (dataChange) awaitProviderEcho()
AppLanguage.applyDefault(context)
- redrawWidgets(context)
+ redrawWidgets(context, if (dataChange) EVENT_WIDGET_KINDS else WidgetKind.entries)
}
}
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 a7e272d..14ad34b 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
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.InAppLanguage
+import de.jeanlucmakiola.calendula.widget.relativeDayWord
+import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
@@ -82,7 +84,6 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
-import java.util.Locale
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
@@ -468,12 +469,8 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
- val relative = when (date) {
- today -> context.getString(R.string.agenda_header_today)
- today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
- else -> null
- }
- val locale = Locale.getDefault()
+ val relative = relativeDayWord(context, date, today)
+ val locale = widgetLocale()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (compact: no year), rather
// than a hardcoded day-month layout.
@@ -483,13 +480,13 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
- locale = Locale.getDefault(),
+ locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
)
private fun weekdayShort(date: LocalDate): String =
- date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
+ date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, widgetLocale())
private fun eventTimeSummary(
context: Context,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt
index 1866d0d..c11d7b7 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.calendula.domain.dateZone
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
@@ -148,7 +149,7 @@ private fun CountdownEventPicker(
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
- val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
+ val date = formatter.format(candidate.event.start.toLocalDateTime(candidate.event.dateZone(zone)).date.toJavaLocalDate())
GroupedRow(
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
index 5d44ea6..5847530 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.WidgetSettings
@@ -23,6 +24,7 @@ import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
@@ -55,6 +57,7 @@ import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.ThemeMode
import javax.inject.Inject
+import kotlinx.datetime.TimeZone
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
@@ -68,7 +71,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
-import java.util.Locale
/** What the preview draws, loaded with the draft's options. */
sealed interface WidgetPreviewData {
@@ -187,7 +189,7 @@ class WidgetConfigViewModel @Inject constructor(
),
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
appTheme = settings.themeMode,
- weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
+ weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
placedSize = placedSize(kind),
preview = null,
)
@@ -237,11 +239,31 @@ class WidgetConfigViewModel @Inject constructor(
viewModelScope.launch {
val candidates = try {
val now = Clock.System.now()
- val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
- val perEvent = occurrences.groupBy { it.eventId }
+ val zone = TimeZone.currentSystemDefault()
+ // A year, a quarter at a time, keeping only each event's first
+ // occurrence so busy series don't hold the whole year in memory.
+ val first = LinkedHashMap()
+ val repeats = HashSet()
+ for (quarter in 0 until 4) {
+ val from = now + (quarter * 91).days
+ val to = if (quarter == 3) now + 365.days else now + ((quarter + 1) * 91).days
+ repository.instances(from..to).first()
+ .filter { !it.hasEnded(now, zone) }
+ .forEach { occurrence ->
+ val known = first[occurrence.eventId]
+ when {
+ known == null -> first[occurrence.eventId] = occurrence
+ known.start == occurrence.start -> Unit
+ else -> {
+ repeats += occurrence.eventId
+ if (occurrence.start < known.start) first[occurrence.eventId] = occurrence
+ }
+ }
+ }
+ }
CountdownCandidates.Ready(
- perEvent.values
- .map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
+ first.values
+ .map { CountdownCandidate(it, repeats = it.eventId in repeats) }
.sortedBy { it.event.start },
)
} catch (e: CancellationException) {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt
index b707972..30ba653 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt
@@ -45,7 +45,7 @@ fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): Countdow
return when {
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
- delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
+ delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceIn(1, 23))
else -> {
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt
index 97c0594..c332ce6 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt
@@ -1,23 +1,21 @@
package de.jeanlucmakiola.calendula.widget.countdown
-import android.app.AlarmManager
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.content.BroadcastReceiver
import android.content.Context
-import android.content.Intent
-import android.os.Build
-import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.state.updateAppWidgetState
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import kotlinx.coroutines.CancellationException
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.Themed
+import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
+import de.jeanlucmakiola.calendula.widget.WidgetTicks
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
@@ -25,8 +23,6 @@ import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.calendula.widget.withoutCalendars
-import de.jeanlucmakiola.floret.android.goAsync
-import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
@@ -104,64 +100,39 @@ internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed
+ try {
+ var moved = false
+ updateAppWidgetState(context, id) { prefs ->
+ if (prefs[EventKey] == oldEventId) {
+ prefs[EventKey] = newEventId
+ moved = true
+ }
}
+ if (moved) CountdownWidget().updateWithRefresh(context, id)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ // Gone mid-pass; nothing left to point anywhere.
}
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt
index f835c7b..eccbcee 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt
@@ -64,7 +64,7 @@ import kotlinx.datetime.toLocalDateTime
/**
* Countdown (#439): to the next timed event, or to a chosen event or series
* ("Vacation · in 12 days"). Redraws when its label changes, see
- * [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
+ * [nextLabelChange] and [WidgetTicks]. Tapping opens the event.
*/
class CountdownWidget : GlanceAppWidget() {
@@ -90,11 +90,6 @@ class CountdownWidget : GlanceAppWidget() {
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
-
- override fun onDeleted(context: Context, appWidgetIds: IntArray) {
- super.onDeleted(context, appWidgetIds)
- appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
- }
}
@Composable
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 ad6e635..227405c 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
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.InAppLanguage
+import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
@@ -70,6 +71,7 @@ import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.floret.glance.WidgetData
@@ -86,7 +88,6 @@ import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import java.time.format.TextStyle as JavaTextStyle
-import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
@@ -104,7 +105,7 @@ private fun gridChromeHeight(fontScale: Float): Dp =
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
private fun yearMonthOf(index: Int): YearMonth =
- YearMonth(index / 12, Month(index % 12 + 1))
+ firstDayOfMonthIndex(index).let { YearMonth(it.year, it.month) }
/**
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
@@ -357,7 +358,7 @@ private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackgrou
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List {
- val locale = Locale.getDefault()
+ val locale = widgetLocale()
return (0 until 7).map { i ->
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
.getDisplayName(JavaTextStyle.NARROW, locale)
@@ -383,13 +384,10 @@ private fun PermissionMessage() {
}
}
-// Locale.getDefault() rather than currentLocale(): Glance has no
-// LocalConfiguration, and the widget is re-rendered on a configuration change
-// anyway (same convention as AgendaWidget).
private fun monthLabel(month: YearMonth, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
- locale = Locale.getDefault(),
+ locale = widgetLocale(),
currentYear = currentYear,
skeleton = "LLLL",
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt
index f397998..6ed2d29 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt
@@ -65,8 +65,10 @@ import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
+import de.jeanlucmakiola.calendula.widget.WidgetSettings
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
+import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
@@ -122,9 +124,13 @@ sealed interface MonthDayData {
}
/** [loaded]'s month window with what the day list draws by. */
-internal suspend fun Context.loadMonthDay(loaded: Themed, options: AgendaWidgetOptions): Themed {
+internal suspend fun Context.loadMonthDay(
+ loaded: Themed,
+ options: AgendaWidgetOptions,
+ settings: WidgetSettings? = null,
+): Themed {
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
- val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ val settings = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(
MonthDayData.Ready(
source = ready,
@@ -158,7 +164,8 @@ class MonthDayWidget : GlanceAppWidget() {
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
val center = monthWindowCenter(shown)
val load: suspend (Int, Long) -> Themed = { c, s ->
- context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
+ val ctx = context.widgetLoadContext(id)
+ context.loadMonthDay(context.loadCachedMonthSource(id, c, s, ctx), ctx.options, ctx.settings)
}
val widgetData = WidgetData(load(center, stamp), center, stamp)
provideContent {
@@ -198,9 +205,17 @@ class ShiftMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0
val current = monthIndexOf(today(systemZone()))
+ // The selection moves with the page — to today on this month, else the
+ // 1st — so the list never shows a day outside the loaded window.
updateAppWidgetState(context, glanceId) { prefs ->
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
- if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
+ if (next == current) {
+ prefs.remove(MONTH_INDEX_KEY)
+ prefs.remove(SELECTED_KEY)
+ } else {
+ prefs[MONTH_INDEX_KEY] = next
+ prefs[SELECTED_KEY] = firstDayOfMonthIndex(next).toString()
+ }
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
@@ -223,7 +238,7 @@ class ResetMonthDayAction : ActionCallback {
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List {
- val first = LocalDate(index / 12, index % 12 + 1, 1)
+ val first = firstDayOfMonthIndex(index)
val start = weekOf(first, weekStart).start
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
val weeks = (start.daysUntilInclusive(last) + 6) / 7
@@ -278,7 +293,7 @@ private fun Header(index: Int, today: LocalDate) {
val context = LocalContext.current
WidgetHeader(
title = formatCalendarTitle(
- date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
+ date = firstDayOfMonthIndex(index).toJavaLocalDate(),
locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
@@ -331,7 +346,7 @@ private fun Grid(
width: Dp,
) {
val locale = widgetLocale()
- val month = index % 12 + 1
+ val month = firstDayOfMonthIndex(index).month.ordinal + 1
val backing = GlanceModifier.wallpaperBacking(radius = 14.dp)
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt
index 30ac5de..584db6c 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt
@@ -51,6 +51,11 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.Themed
+import de.jeanlucmakiola.calendula.widget.WidgetTicks
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.domain.localEnd
+import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
@@ -95,9 +100,9 @@ class TodayWidget : GlanceAppWidget() {
provideSamplePreview(context, WidgetKind.TODAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
- val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
+ val widgetData = WidgetData(context.loadPlacedToday(id), refreshStamp(context, id))
provideContent {
- val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedToday(id) }
InAppLanguage {
TodayWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
}
@@ -113,6 +118,21 @@ internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(l
today..today.plus(1, DateTimeUnit.DAY)
}
+/** [loadTodayList] for placed widget [id], arming its redraw for when the next of today's events ends. */
+private suspend fun Context.loadPlacedToday(id: GlanceId): Themed {
+ val loaded = loadTodayList(widgetLoadContext(id))
+ val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
+ val data = loaded.content as? DaysWidgetData.Ready
+ val zone = systemZone()
+ val nextEnd = data?.eventsOn(data.today)?.map { it.localEnd(zone) }?.filter { it > data.now }?.minOrNull()
+ if (nextEnd == null) {
+ WidgetTicks.cancel(this, WidgetKind.TODAY, appWidgetId)
+ } else {
+ WidgetTicks.schedule(this, WidgetKind.TODAY, appWidgetId, nextEnd)
+ }
+ return loaded
+}
+
private val PillWidth = 44.dp
private val RowGap = 2.dp
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt
index fd660f9..81cb2c2 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt
@@ -117,13 +117,15 @@ internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppe
when (data) {
DaysWidgetData.NeedsPermission ->
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
+ // The gap is padding on each tile rather than a Spacer between them:
+ // Glance drops a row's children past the tenth, and 7 tiles plus 6
+ // spacers would lose the weekend.
is DaysWidgetData.Ready -> Row(
- modifier = GlanceModifier.fillMaxSize().padding(6.dp),
+ modifier = GlanceModifier.fillMaxSize().padding(horizontal = 6.dp - DayGap / 2, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (i in 0 until 7) {
- if (i > 0) Spacer(GlanceModifier.width(DayGap))
- Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
+ Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = DayGap / 2)) {
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5d97821..5e5bf96 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -637,10 +637,10 @@
Agenda range
How far ahead the Agenda screen lists events.
Agenda widget range
- How far ahead the agenda home-screen widget lists events.
+ How far ahead this widget lists events.
Agenda widget size
- How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it.
+ How large this widget draws its text.
Small
Medium
Large
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/HasEndedTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HasEndedTest.kt
new file mode 100644
index 0000000..f25f55f
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HasEndedTest.kt
@@ -0,0 +1,40 @@
+package de.jeanlucmakiola.calendula.domain
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.testEvent
+import kotlinx.datetime.TimeZone
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class HasEndedTest {
+
+ private val newYork = TimeZone.of("America/New_York")
+
+ // All-day Oct 28, stored as UTC midnights.
+ private val holiday = testEvent(
+ title = "Holiday",
+ start = Instant.parse("2026-10-28T00:00:00Z"),
+ end = Instant.parse("2026-10-29T00:00:00Z"),
+ isAllDay = true,
+ )
+
+ @Test
+ fun `an all-day event runs until local midnight west of UTC`() {
+ // 20:00 in New York, past the stored UTC end.
+ assertThat(holiday.hasEnded(Instant.parse("2026-10-29T00:00:00Z"), newYork)).isFalse()
+ assertThat(holiday.hasEnded(Instant.parse("2026-10-29T04:00:00Z"), newYork)).isTrue()
+ }
+
+ @Test
+ fun `an all-day event ends at local midnight east of UTC`() {
+ val berlin = TimeZone.of("Europe/Berlin")
+ assertThat(holiday.hasEnded(Instant.parse("2026-10-28T23:30:00Z"), berlin)).isTrue()
+ }
+
+ @Test
+ fun `a timed event ends at its own end`() {
+ val call = holiday.copy(isAllDay = false, end = Instant.parse("2026-10-28T15:00:00Z"))
+ assertThat(call.hasEnded(Instant.parse("2026-10-28T15:00:00Z"), newYork)).isTrue()
+ assertThat(call.hasEnded(Instant.parse("2026-10-28T14:59:00Z"), newYork)).isFalse()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
index 285e9ed..6cd4ed4 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
@@ -27,6 +27,11 @@ class CountdownTest {
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
)
+ @Test
+ fun `the last half hour before the day mark still reads 23 h`() {
+ assertThat(countdownLabel(event(now + 23.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 23))
+ }
+
@Test
fun `labels step from days to hours to minutes to now`() {
assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0))