Fix widget review findings for 2.24
Week strip lost its weekend to Glance's 10-child row limit, widget taps shared PendingIntents across kinds, Month + day kept a stale selection, all-day events ended at UTC midnight, countdown missed edited and split occurrences, Today never redrew when its last event ended, and redraw receivers could crash on removed widgets. Plus redraw scoping, caching and locale cleanups.
This commit is contained in:
1 parent
14c10c6d0b
commit
cd3fb7378f
29 files changed
+474
-155
No files matched your search
@@ -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)
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+28
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user