Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiola 2a55bc3baa Fix widget review findings for 2.24 (#452)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 18:45:19 +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))