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