Countdown widget: next timed event or a chosen event/series, redrawn when its label changes (#439)

This commit is contained in:
2026-10-23 18:51:49 +02:00
parent 075940d29e
commit 343a8904ea
17 changed files with 981 additions and 14 deletions
+17
View File
@@ -412,6 +412,23 @@
android:resource="@xml/appwidget_info_today" />
</receiver>
<receiver
android:name=".widget.countdown.CountdownWidgetReceiver"
android:label="@string/widget_countdown_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_countdown" />
</receiver>
<!-- A countdown widget's redraw for when its label changes (#439). -->
<receiver
android:name=".widget.countdown.CountdownTickReceiver"
android:exported="false" />
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
@@ -56,6 +56,10 @@ import javax.inject.Singleton
interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
/** [instances] of event [eventId] alone, soonest first. */
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
instances(beginMillis, endMillis).filter { it.eventId == eventId }
/**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see
@@ -563,21 +567,33 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
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()))
private fun queryInstances(
beginMillis: Long,
endMillis: Long,
extraSelection: String?,
args: Array<String>?,
): List<EventInstance> {
ensureObserversRegistered()
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0"
return resolver.query(
instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null,
if (extraSelection == null) visible else "$visible AND $extraSelection",
args,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
}
@@ -16,6 +16,9 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
@@ -194,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor(
cachedCalendars
}
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
withContext(io) {
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId)
@@ -101,9 +101,10 @@ fun TimeZonePickerDialog(
onDismiss = onDismiss,
scrollable = false,
) {
SearchField(
PickerSearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.event_edit_timezone_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
@@ -181,9 +182,10 @@ fun TimeZonePickerDialog(
* which has a top bar to put it in) because the picker's bar is the title.
*/
@Composable
private fun SearchField(
internal fun PickerSearchField(
query: String,
onQueryChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
) {
val keyboard = LocalSoftwareKeyboardController.current
@@ -206,7 +208,7 @@ private fun SearchField(
InlineTextField(
value = query,
onValueChange = onQueryChange,
placeholder = stringResource(R.string.event_edit_timezone_search),
placeholder = placeholder,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
@@ -20,6 +20,8 @@ import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
/** A content option a widget's config screen offers, besides background and theme. */
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
@@ -77,6 +79,12 @@ enum class WidgetKind(
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(300.dp, 100.dp),
),
COUNTDOWN(
CountdownWidgetReceiver::class.java,
R.string.widget_countdown_label,
setOf(WidgetOption.CALENDARS),
DpSize(200.dp, 100.dp),
),
;
fun widget(): GlanceAppWidget = when (this) {
@@ -87,6 +95,7 @@ enum class WidgetKind(
WEEK_STRIP -> WeekStripWidget()
UPCOMING -> UpcomingWidget()
TODAY -> TodayWidget()
COUNTDOWN -> CountdownWidget()
}
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
@@ -0,0 +1,178 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
@Composable
internal fun CountdownOptionsSection(
options: CountdownOptions,
candidates: CountdownCandidates,
onChange: (CountdownOptions) -> Unit,
onLoadCandidates: () -> Unit,
) {
var picking by rememberSaveable { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_countdown),
options = CountdownMode.entries,
selected = options.mode,
label = {
stringResource(
when (it) {
CountdownMode.NEXT -> R.string.widget_config_countdown_next
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
},
)
},
onSelect = { mode ->
onChange(options.copy(mode = mode))
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
},
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
)
when (options.mode) {
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
CountdownMode.PINNED -> GroupedRow(
title = stringResource(R.string.widget_config_countdown_event),
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
position = Position.Bottom,
onClick = { picking = true },
)
}
if (picking) {
LaunchedEffect(Unit) { onLoadCandidates() }
CountdownEventPicker(
candidates = candidates,
selectedEventId = options.eventId,
onRetry = onLoadCandidates,
onSelect = { candidate ->
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
picking = false
},
onDismiss = { picking = false },
)
}
}
@Composable
private fun CountdownEventPicker(
candidates: CountdownCandidates,
selectedEventId: Long?,
onRetry: () -> Unit,
onSelect: (CountdownCandidate) -> Unit,
onDismiss: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
FullScreenPicker(
title = stringResource(R.string.widget_config_countdown_event),
onDismiss = onDismiss,
predictiveBack = true,
scrollable = false,
) {
PickerSearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.widget_config_countdown_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
when (candidates) {
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
CountdownCandidates.Failed -> Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.state_retry))
}
}
is CountdownCandidates.Ready -> {
val locale = currentLocale()
val matches = remember(candidates, query) {
val needle = query.trim()
if (needle.isEmpty()) candidates.events
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
}
if (matches.isEmpty()) {
PickerMessage(
stringResource(R.string.widget_config_countdown_empty),
Modifier.fillMaxWidth().padding(24.dp),
)
} else {
val zone = remember { TimeZone.currentSystemDefault() }
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())
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,
position = positionOf(index, matches.size),
selected = candidate.event.eventId == selectedEventId,
minHeight = 56.dp,
leading = { CalendarColorChip(candidate.event.color) },
onClick = { onSelect(candidate) },
)
}
}
}
}
}
}
}
@Composable
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier,
)
}
@@ -115,6 +115,8 @@ class WidgetConfigActivity : ComponentActivity() {
onAppearance = viewModel::setAppearance,
onContent = viewModel::setContent,
onCalendarShown = viewModel::setCalendarShown,
onCountdown = viewModel::setCountdown,
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
)
}
}
@@ -53,6 +53,8 @@ import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
@@ -79,6 +81,8 @@ internal fun WidgetConfigScreen(
onAppearance: (WidgetAppearance) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
) {
val ready = state as? WidgetConfigUiState.Ready
val surface = MaterialTheme.colorScheme.surface
@@ -152,6 +156,8 @@ internal fun WidgetConfigScreen(
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
is WidgetPreviewData.Countdown ->
CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
is WidgetPreviewData.Days -> when (state.kind) {
WidgetKind.TODAY ->
TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
@@ -172,6 +178,8 @@ internal fun WidgetConfigScreen(
onAppearance = onAppearance,
onContent = onContent,
onCalendarShown = onCalendarShown,
onCountdown = onCountdown,
onLoadCountdownCandidates = onLoadCountdownCandidates,
modifier = Modifier
.weight(1f)
.background(surface),
@@ -200,6 +208,8 @@ private fun ConfigOptions(
onAppearance: (WidgetAppearance) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -225,6 +235,14 @@ private fun ConfigOptions(
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
position = Position.Bottom,
)
if (state.kind == WidgetKind.COUNTDOWN) {
CountdownOptionsSection(
options = state.draft.countdown,
candidates = state.candidates,
onChange = onCountdown,
onLoadCandidates = onLoadCountdownCandidates,
)
}
if (state.kind.options.isNotEmpty()) {
ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
}
@@ -22,6 +22,14 @@ import de.jeanlucmakiola.calendula.widget.loadDays
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.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
@@ -66,6 +74,7 @@ sealed interface WidgetPreviewData {
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
data class Today(val today: LocalDate) : WidgetPreviewData
data class Days(val data: DaysWidgetData) : WidgetPreviewData
data class Countdown(val data: CountdownData) : WidgetPreviewData
}
/** The options being edited, applied to the preview as they change and written on save. */
@@ -74,8 +83,20 @@ data class WidgetDraft(
/** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
val content: AgendaWidgetOptions,
val hiddenCalendars: Set<Long>,
val countdown: CountdownOptions = CountdownOptions(),
)
/** One event or series the countdown can be pinned to, at its next occurrence. */
data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
/** The countdown's event list, loaded when its picker first opens. */
sealed interface CountdownCandidates {
data object Idle : CountdownCandidates
data object Loading : CountdownCandidates
data object Failed : CountdownCandidates
data class Ready(val events: List<CountdownCandidate>) : CountdownCandidates
}
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
sealed interface WidgetConfigUiState {
@@ -98,6 +119,7 @@ sealed interface WidgetConfigUiState {
val placedSize: DpSize,
/** Null until the first preview load lands. */
val preview: WidgetPreviewData?,
val candidates: CountdownCandidates = CountdownCandidates.Idle,
/** The first preview load failed, so there's nothing to show. */
val previewFailed: Boolean = false,
val saving: Boolean = false,
@@ -158,6 +180,7 @@ class WidgetConfigViewModel @Inject constructor(
appearance = stored.widgetAppearance,
content = stored.agendaWidgetOptions(settings.agendaDefaults),
hiddenCalendars = stored.hiddenCalendars,
countdown = stored.countdownOptions,
),
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
appTheme = settings.themeMode,
@@ -198,6 +221,35 @@ class WidgetConfigViewModel @Inject constructor(
reloadPreview()
}
fun setCountdown(options: CountdownOptions) {
edit { it.copy(draft = it.draft.copy(countdown = options)) }
reloadPreview()
}
/** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
fun loadCountdownCandidates() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
edit { it.copy(candidates = CountdownCandidates.Loading) }
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 }
CountdownCandidates.Ready(
perEvent.values
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
.sortedBy { it.event.start },
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
CountdownCandidates.Failed
}
edit { it.copy(candidates = candidates) }
}
}
fun setCalendarShown(id: Long, shown: Boolean) {
edit {
val hidden = it.draft.hiddenCalendars
@@ -241,6 +293,8 @@ class WidgetConfigViewModel @Inject constructor(
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
WidgetKind.COUNTDOWN ->
WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
}
}
@@ -257,6 +311,7 @@ class WidgetConfigViewModel @Inject constructor(
state.putAppearance(ready.draft.appearance)
if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
}
_events.send(WidgetConfigEvent.SAVED)
} catch (e: CancellationException) {
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** What a countdown widget counts down to (#439). */
enum class CountdownMode { NEXT, PINNED }
data class CountdownOptions(
val mode: CountdownMode = CountdownMode.NEXT,
/** The pinned event or series; a series follows its next occurrence. */
val eventId: Long? = null,
/** The pinned event's title when it was chosen, for the config screen. */
val title: String? = null,
)
enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
/**
* The label for [event] at [now]. Under a minute reads "now", under an hour
* counts minutes (rounded up), under a day whole hours (rounded), and beyond
* that calendar days in [zone], so "in 1 day" is always tomorrow.
*/
fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
val running = event.start <= now
val target = target(event, now)
val delta = target - now
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))
else -> {
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
}
}
}
/**
* When [countdownLabel] next reads differently, so the widget redraws then
* and not before: every minute in the last hour, at each half hour within the
* day, and at midnight or the 24-hour mark beyond that.
*/
fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
val target = target(event, now)
val label = countdownLabel(event, now, zone)
val lowerBound: Duration = when (label.unit) {
CountdownUnit.NOW -> Duration.ZERO
CountdownUnit.MINUTES -> (label.amount - 1).minutes
CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
CountdownUnit.DAYS -> {
val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
}
}
return (target - lowerBound).coerceAtLeast(now + 1.minutes)
}
/**
* The next-event mode's pick: timed, not declined, not over; of those, the one
* whose countdown changes soonest — a running event's end, else a start.
*/
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
instances
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one. */
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
/**
* The span a countdown counts over. An all-day event is stored on UTC
* midnights; it starts and ends at local midnight of those dates instead.
*/
fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
)
@@ -0,0 +1,167 @@
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 de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
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
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
private val EventKey = longPreferencesKey("cfg_countdown_event")
private val TitleKey = stringPreferencesKey("cfg_countdown_title")
internal val Preferences.countdownOptions: CountdownOptions
get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
this[ModeKey] = options.mode.name
if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
}
/** How far ahead the next-event mode looks; past this it shows no event. */
private val NextEventHorizon = 30.days
/** How far ahead a pinned event's next occurrence is looked for. */
private val PinnedHorizon = 730.days
sealed interface CountdownData {
data object NeedsPermission : CountdownData
data class Ready(
val options: CountdownOptions,
/** The occurrence counted to; null when there is none to show. */
val event: EventInstance?,
val now: Instant,
val today: LocalDate,
val is24Hour: Boolean,
val soften: Boolean,
/** The placed widget, for opening its settings; null in a preview. */
val appWidgetId: Int?,
) : CountdownData
}
internal suspend fun Context.loadCountdown(
load: WidgetLoadContext,
options: CountdownOptions,
appWidgetId: Int?,
): Themed<CountdownData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
val now = Clock.System.now()
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
}
return Themed(
CountdownData.Ready(
options = options,
event = event,
now = now,
today = today(systemZone()),
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
appWidgetId = appWidgetId,
),
settings.themeMode,
)
}
/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<CountdownData> {
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
val event = (loaded.content as? CountdownData.Ready)?.event
if (event == null) {
CountdownTicks.cancel(this, appWidgetId)
} else {
val zone = systemZone()
CountdownTicks.schedule(this, 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.
*/
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)
}
}
}
}
@@ -0,0 +1,222 @@
package de.jeanlucmakiola.calendula.widget.countdown
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.formatWidgetTime
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.minus
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.
*/
class CountdownWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
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
internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is CountdownData.Ready -> {
val event = data.event
when {
event != null -> EventCountdown(event, data)
data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
data.options.mode == CountdownMode.PINNED ->
Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
else -> Message(
context.getString(R.string.widget_upcoming_empty),
actionStartActivity(MainActivity.openViewIntent(context, null)),
)
}
}
}
}
}
private fun configAction(context: Context, appWidgetId: Int?): Action? =
appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
@Composable
private fun Message(text: String, action: Action?) {
val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
WidgetMessage(text, modifier = modifier)
}
@Composable
private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
val context = LocalContext.current
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
val open = actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
)
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
} else {
GlanceModifier
}
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
Row(
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.fillMaxHeight()
.cornerRadius(2.dp)
.background(
themedColor(
day = eventAccent(event.color, dark = false, data.soften),
night = eventAccent(event.color, dark = true, data.soften),
),
),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
)
Text(
text = countdownText(context.resources, label),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
)
Text(
text = whenText(context, event, data),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
CountdownUnit.MINUTES -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
label.amount,
label.amount,
)
CountdownUnit.HOURS -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
label.amount,
label.amount,
)
CountdownUnit.DAYS -> when {
label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
else -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
label.amount,
label.amount,
)
}
}
/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
val counted = event.counted(systemZone())
val running = counted.start <= data.now
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
return when {
event.isAllDay && running -> context.getString(
R.string.widget_countdown_until,
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
)
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
}
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout). -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingHorizontal="14dp"
tools:ignore="HardcodedText">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Vacation"
android:textColor="@color/widget_preview_on_surface"
android:textSize="13sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="in 12 days"
android:textColor="@color/widget_preview_primary"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
+45
View File
@@ -447,6 +447,51 @@
<string name="widget_upcoming_empty">No upcoming events</string>
<string name="widget_today_label">Calendula today</string>
<string name="widget_nothing_left_today">Nothing left today</string>
<string name="widget_countdown_label">Calendula countdown</string>
<string name="widget_countdown_now">Now</string>
<string name="widget_countdown_ending_now">Ending now</string>
<string name="widget_countdown_tomorrow">Tomorrow</string>
<string name="widget_countdown_ends_tomorrow">Ends tomorrow</string>
<!-- A running all-day event's last day: "Until Fri" -->
<string name="widget_countdown_until">Until %1$s</string>
<plurals name="widget_countdown_in_minutes">
<item quantity="one">in %d min</item>
<item quantity="other">in %d min</item>
</plurals>
<plurals name="widget_countdown_in_hours">
<item quantity="one">in %d h</item>
<item quantity="other">in %d h</item>
</plurals>
<plurals name="widget_countdown_in_days">
<item quantity="one">in %d day</item>
<item quantity="other">in %d days</item>
</plurals>
<plurals name="widget_countdown_ends_minutes">
<item quantity="one">ends in %d min</item>
<item quantity="other">ends in %d min</item>
</plurals>
<plurals name="widget_countdown_ends_hours">
<item quantity="one">ends in %d h</item>
<item quantity="other">ends in %d h</item>
</plurals>
<plurals name="widget_countdown_ends_days">
<item quantity="one">ends in %d day</item>
<item quantity="other">ends in %d days</item>
</plurals>
<string name="widget_countdown_choose">Tap to choose an event to count down to</string>
<!-- A pinned countdown whose event has no occurrence left: "Vacation is over" -->
<string name="widget_countdown_over">%1$s is over</string>
<string name="widget_config_countdown">Count down to</string>
<string name="widget_config_countdown_next">Next event</string>
<string name="widget_config_countdown_pinned">Chosen event</string>
<string name="widget_config_countdown_next_hint">The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped.</string>
<string name="widget_config_countdown_event">Event</string>
<string name="widget_config_countdown_event_none">None chosen yet</string>
<string name="widget_config_countdown_search">Search events</string>
<string name="widget_config_countdown_empty">No events in the coming year match.</string>
<string name="widget_config_countdown_load_failed">Couldn\'t load your events.</string>
<!-- A repeating event in the countdown's event list: "Next: Fri, 24 Oct · repeats" -->
<string name="widget_config_countdown_repeats">%1$s · repeats</string>
<string name="widget_refresh">Refresh</string>
<string name="widget_new_event">New event</string>
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_countdown_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_countdown"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.widget.countdown
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
class CountdownTest {
private val zone = TimeZone.UTC
private val now = Instant.parse("2026-10-21T12:00:00Z")
private fun event(
start: Instant,
end: Instant = start + 1.hours,
id: Long = 1,
allDay: Boolean = false,
response: EventResponse = EventResponse.Going,
) = EventInstance(
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
)
@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))
assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25))
assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3))
assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1))
// Tomorrow 23:00 is 35 h away but one calendar day.
assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1))
assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12))
}
@Test
fun `a running event counts to its end`() {
val running = event(start = now - 20.minutes, end = now + 10.minutes)
assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10))
}
@Test
fun `the next redraw lands where the label changes`() {
// "in 25 min" turns into "in 24 min" a minute later.
assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes)
// "in 3 h" (2 h 40 min) holds until 2 h 30 min are left.
assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes)
// "in 1 h" holds until the last hour, then counts minutes.
assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes)
// Days change at midnight.
assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z"))
}
@Test
fun `next mode skips all-day, declined and finished events`() {
val allDay = event(now + 1.hours, id = 1, allDay = true)
val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined)
val over = event(now - 2.hours, now - 1.hours, id = 3)
val later = event(now + 3.hours, id = 4)
val running = event(now - 1.hours, now + 2.hours, id = 5)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
// A running event ending before the next one starts comes first.
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
}
@Test
fun `a pinned series follows its next occurrence`() {
val past = event(now - 7.days)
val next = event(now + 7.days)
assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next)
assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
}
@Test
fun `an all-day event counts in local days west of UTC`() {
val newYork = TimeZone.of("America/New_York")
// 21 Oct, 20:00 in New York; the vacation is all day on the 23rd.
val evening = Instant.parse("2026-10-22T00:00:00Z")
val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true)
val counted = vacation.counted(newYork)
assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z"))
assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2))
}
}