Countdown widget: next timed event or a chosen event/series, redrawn when its label changes (#439)
This commit is contained in:
@@ -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. */
|
||||
|
||||
+178
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user