Compare commits

...
Author SHA1 Message Date
makiolaj cd3fb7378f Fix widget review findings for 2.24
Week strip lost its weekend to Glance's 10-child row limit, widget taps
shared PendingIntents across kinds, Month + day kept a stale selection,
all-day events ended at UTC midnight, countdown missed edited and split
occurrences, Today never redrew when its last event ended, and redraw
receivers could crash on removed widgets. Plus redraw scoping, caching
and locale cleanups.
2026-10-28 18:03:22 +01:00
29 changed files with 474 additions and 155 deletions
+7 -1
View File
@@ -426,7 +426,7 @@
<!-- A countdown widget's redraw for when its label changes (#439). -->
<receiver
android:name=".widget.countdown.CountdownTickReceiver"
android:name=".widget.WidgetTickReceiver"
android:exported="false" />
<receiver
@@ -450,6 +450,12 @@
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. -->
<!-- The app's own redraw request (language change); not exported so
other apps can't trigger it. -->
<receiver
android:name=".widget.WidgetRedrawReceiver"
android:exported="false" />
<receiver
android:name=".widget.WidgetUpdateReceiver"
android:exported="true">
@@ -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)
}
@@ -571,7 +571,14 @@ class AndroidCalendarDataSource @Inject constructor(
queryInstances(beginMillis, endMillis, null, null)
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
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,
@@ -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<SeriesSplit> 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)
@@ -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<SeriesSplit>(extraBufferCapacity = 8)
override val seriesSplits: Flow<SeriesSplit> = splits
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
dataSource.deleteOccurrence(eventId, beginMillis)
@@ -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
@@ -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
@@ -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)
}
@@ -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<MonthWidgetSource.Ready>)
private val entries = ConcurrentHashMap<GlanceId, Entry>()
// Keyed by app-widget id, which a removed widget can still be named by.
private val entries = ConcurrentHashMap<Int, Entry>()
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
fun get(id: Int, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
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<MonthWidgetSource>) {
fun put(id: Int, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
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<MonthWidgetSource> =
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<MonthWidgetSource> {
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<Long>): Themed<MonthWidgetSource> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
internal suspend fun Context.loadThemedMonthSource(
centerIndex: Int,
hiddenCalendars: Set<Long>,
settings: WidgetSettings? = null,
): Themed<MonthWidgetSource> {
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)
@@ -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()
@@ -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())
@@ -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()
@@ -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> = 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> = 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 <T> watch(changes: Flow<T>, onChange: (T) -> Unit) {
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
changes.collect { onChange() }
changes.collect { onChange(it) }
}
}
}
@@ -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)
}
}
}
@@ -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.
}
}
}
}
@@ -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)
}
}
@@ -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,
@@ -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,
@@ -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<Long, EventInstance>()
val repeats = HashSet<Long>()
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) {
@@ -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))
@@ -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<Countdown
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
val event = (loaded.content as? CountdownData.Ready)?.event
if (event == null) {
CountdownTicks.cancel(this, appWidgetId)
WidgetTicks.cancel(this, WidgetKind.COUNTDOWN, appWidgetId)
} else {
val zone = systemZone()
CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
WidgetTicks.schedule(
this,
WidgetKind.COUNTDOWN,
appWidgetId,
nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone),
)
}
return loaded
}
/**
* One alarm per countdown widget, for the moment its label changes. 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.
* Moves countdowns pinned to [oldEventId] on to [newEventId] after the app
* split that series, so they keep counting to the part that continues it.
*/
internal object CountdownTicks {
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
fun schedule(context: Context, appWidgetId: Int, at: Instant) {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
val intent = pendingIntent(context, 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, appWidgetId: Int) {
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId))
}
private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast(
context,
0,
Intent(context, CountdownTickReceiver::class.java)
.setAction(ACTION_TICK)
// Keys the PendingIntent per widget.
.setData("calendula://countdown/$appWidgetId".toUri())
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Reloads the countdown widget whose label just changed; the load arms the next tick. */
class CountdownTickReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
val appContext = context.applicationContext
goAsync {
try {
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
CountdownWidget().updateWithRefresh(appContext, glanceId)
} catch (_: IllegalArgumentException) {
// The widget is gone.
CountdownTicks.cancel(appContext, appWidgetId)
internal suspend fun repointCountdowns(context: Context, oldEventId: Long, newEventId: Long) {
val manager = GlanceAppWidgetManager(context)
manager.getGlanceIds(CountdownWidget::class.java).forEach { id ->
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.
}
}
}
@@ -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
@@ -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<String> {
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",
)
@@ -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<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
internal suspend fun Context.loadMonthDay(
loaded: Themed<MonthWidgetSource>,
options: AgendaWidgetOptions,
settings: WidgetSettings? = null,
): Themed<MonthDayData> {
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<MonthDayData> = { 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<LocalDate> {
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()) {
@@ -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<Preferences>().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<DaysWidgetData> {
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
@@ -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)
}
}
+2 -2
View File
@@ -637,10 +637,10 @@
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_agenda_widget_range_hint">How far ahead this widget lists events.</string>
<string name="settings_widget_size">Agenda widget size</string>
<string name="settings_widget_size_hint">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.</string>
<string name="settings_widget_size_hint">How large this widget draws its text.</string>
<string name="settings_widget_size_small">Small</string>
<string name="settings_widget_size_medium">Medium</string>
<string name="settings_widget_size_large">Large</string>
@@ -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()
}
}
@@ -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))