feat(#459): reminders at a set time and a daily summary notification

This commit is contained in:
makiolaj committed 2026-11-13 17:50:21 +01:00
1 parent fa999f8cf0
commit c28582ae6f
27 files changed
+1518 -50

No files matched your search

@@ -67,7 +67,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
@@ -620,6 +623,20 @@ class MainActivity : AppCompatActivity() {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/**
* Open the day view on [date] over the default home view — the daily
* summary's tap (#459). Spoken as the system's own "view time" intent,
* at the day's local noon, so it lands on the path a clock tap takes.
*/
fun openDayIntent(context: Context, date: LocalDate): Intent {
val noon = LocalDateTime(date, LocalTime(12, 0))
.toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
return Intent(Intent.ACTION_VIEW, "content://$CALENDAR_PROVIDER_HOST/time/$noon".toUri())
.setClass(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
@@ -919,11 +920,15 @@ class AndroidCalendarDataSource @Inject constructor(
minutes
}
/** [form]'s reminders as the distinct raw provider offsets to store. */
/**
* [form]'s reminders as the distinct raw provider offsets to store, its
* fixed-time ones (#459) measured from the start it is saved with.
*/
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
form.reminders
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
.distinct()
(
form.reminders.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) } +
form.fixedReminderOffsets(TimeZone.currentSystemDefault())
).distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
// A globally-unique UID so a later .ics backup/restore can identify
@@ -1026,7 +1031,11 @@ class AndroidCalendarDataSource @Inject constructor(
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the
// form's semantic minutes; reconcile works in encoded provider minutes.
if (updated.reminders.toSet() != original.reminders.toSet()) {
// A fixed-time reminder also changes when the start's day or time does.
val zone = TimeZone.currentSystemDefault()
if (updated.reminders.toSet() != original.reminders.toSet() ||
updated.fixedReminderOffsets(zone).toSet() != original.fixedReminderOffsets(zone).toSet()
) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
@@ -5,6 +5,9 @@ import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -177,6 +180,19 @@ class SettingsPrefs @Inject constructor(
prefs.flow(SettingsKeys.perCalendarReminderOverride)
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> =
prefs.flow(SettingsKeys.perCalendarAllDayReminderOverride)
val defaultFixedTimeReminder: Flow<FixedTimeReminder?> = prefs.flow(SettingsKeys.defaultFixedTimeReminder)
val perCalendarFixedTimeReminderOverride: Flow<Map<Long, FixedTimeReminder?>> =
prefs.flow(SettingsKeys.perCalendarFixedTimeReminderOverride)
/**
* Every "N days before at HH:MM" the defaults name, so an offset landing on
* one reads back as that reminder rather than a lead time (#459).
*/
val knownFixedTimeReminders: Flow<Set<FixedTimeReminder>> = prefs.snapshot {
setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
}
val dailyDigest: Flow<DailyDigestSettings> = prefs.snapshot { it.dailyDigest }
val autoBackupEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.autoBackupEnabled)
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
@@ -329,6 +345,31 @@ class SettingsPrefs @Inject constructor(
suspend fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) =
prefs.applyOverride(SettingsKeys.perCalendarAllDayReminderOverride, calendarId, override)
suspend fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) =
prefs.set(SettingsKeys.defaultFixedTimeReminder, reminder)
/** [override] [FixedTimeOverride.Inherit] drops the calendar's entry. */
suspend fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) {
prefs.update(SettingsKeys.perCalendarFixedTimeReminderOverride) { current ->
when (override) {
FixedTimeOverride.Inherit -> current - calendarId
FixedTimeOverride.Off -> current + (calendarId to null)
is FixedTimeOverride.At -> current + (calendarId to override.reminder)
}
}
}
suspend fun setDailyDigestEnabled(enabled: Boolean) = prefs.set(SettingsKeys.dailyDigestEnabled, enabled)
suspend fun setDailyDigestTimeMinutes(minutesOfDay: Int) =
prefs.set(SettingsKeys.dailyDigestTimeMinutes, minutesOfDay)
suspend fun setDailyDigestDay(day: DigestDay) = prefs.set(SettingsKeys.dailyDigestDay, day)
suspend fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) {
prefs.update(SettingsKeys.digestExcludedCalendarIds) { if (included) it - calendarId else it + calendarId }
}
suspend fun setAutoBackupEnabled(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupEnabled, enabled)
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
@@ -378,6 +419,8 @@ class SettingsPrefs @Inject constructor(
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
internal const val MINUTES_PER_DAY = 1_440
/** 20:00, when the daily summary arrives until changed. */
internal const val DEFAULT_DIGEST_TIME = 1_200
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
@@ -694,6 +737,34 @@ object SettingsKeys {
val perCalendarAllDayReminderOverride: Pref<Map<Long, List<Int>>> =
reminderOverridePref("per_calendar_allday_reminder_override")
/**
* The "N days before at HH:MM" reminder added to new **timed** events
* (#459), or null for none — the factory default. Stored as `days@minute`.
*/
val defaultFixedTimeReminder: Pref<FixedTimeReminder?> =
nullableStringPref("default_fixed_time_reminder").map(FixedTimeReminder::parse) { it?.toToken() }
/**
* Per-calendar overrides of [defaultFixedTimeReminder]: present with a
* value replaces it, present as null turns it off, absent inherits.
* Serialised as `id=days@minute` or `id=none`, joined by `;`.
*/
val perCalendarFixedTimeReminderOverride: Pref<Map<Long, FixedTimeReminder?>> =
nullableStringPref("per_calendar_fixed_time_reminder").map(::parseFixedTimeOverrides) {
it.toStoredFixedTimeOverrides()
}
/** The daily summary notification (#459): opt-in, and which day it lists at what time. */
val dailyDigestEnabled: Pref<Boolean> = booleanPref("daily_digest_enabled", false)
val dailyDigestTimeMinutes: Pref<Int> =
intPref("daily_digest_time_minutes", SettingsPrefs.DEFAULT_DIGEST_TIME).coerced {
it.coerceIn(0, SettingsPrefs.MINUTES_PER_DAY - 1)
}
val dailyDigestDay: Pref<DigestDay> = enumPref("daily_digest_day", DigestDay.Tomorrow)
/** Calendars left out of the daily summary; every other one is in. */
val digestExcludedCalendarIds: Pref<Set<Long>> = idSetPref("digest_excluded_calendar_ids")
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
@@ -890,6 +961,56 @@ data class BackupStatus(
val consecutiveFailures: Int,
)
/** The daily summary's settings, read together. */
internal val Preferences.dailyDigest: DailyDigestSettings
get() = DailyDigestSettings(
enabled = this[SettingsKeys.dailyDigestEnabled],
timeMinutes = this[SettingsKeys.dailyDigestTimeMinutes],
day = this[SettingsKeys.dailyDigestDay],
excludedCalendarIds = this[SettingsKeys.digestExcludedCalendarIds],
)
/** A calendar's choice for the fixed-time default, as a picker returns it. */
sealed interface FixedTimeOverride {
data object Inherit : FixedTimeOverride
data object Off : FixedTimeOverride
data class At(val reminder: FixedTimeReminder) : FixedTimeOverride
}
/** [calendarId]'s entry read as a [FixedTimeOverride]. */
fun Map<Long, FixedTimeReminder?>.fixedTimeOverrideFor(calendarId: Long): FixedTimeOverride = when {
!containsKey(calendarId) -> FixedTimeOverride.Inherit
else -> getValue(calendarId)?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off
}
/** The fixed-time reminder a new timed event on [calendarId] gets, or null for none. */
fun resolveDefaultFixedTimeReminder(
global: FixedTimeReminder?,
overrides: Map<Long, FixedTimeReminder?>,
calendarId: Long?,
): FixedTimeReminder? =
if (calendarId != null && overrides.containsKey(calendarId)) overrides[calendarId] else global
private fun parseFixedTimeOverrides(stored: String?): Map<Long, FixedTimeReminder?> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val value = parts[1].trim()
if (value == NONE) return@mapNotNull calendarId to null
val reminder = FixedTimeReminder.parse(value) ?: return@mapNotNull null
calendarId to reminder
}.toMap()
private fun Map<Long, FixedTimeReminder?>.toStoredFixedTimeOverrides(): String? =
if (isEmpty()) {
null
} else {
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, reminder) ->
"$id$KEY_VALUE_SEP${reminder?.toToken() ?: NONE}"
}
}
/**
* The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
@@ -0,0 +1,145 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import kotlinx.datetime.toKotlinLocalDate
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts the daily summary (#459): one notification listing a day's events, on
* its own channel, replacing the one before it. Tapping opens that day.
*/
@Singleton
class DailyDigestNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
/**
* Post the summary of [day] listing [entries], or withdraw the last one when
* there is nothing to list — yesterday's would otherwise stand in for it.
*/
suspend fun post(day: LocalDate, entries: List<ReminderEventInstance>) {
val switchedOff = calendarPrefs.pendingDisabledCalendarIds.first()
val listed = entries.filter { it.calendarId !in switchedOff }
val manager = NotificationManagerCompat.from(context)
if (listed.isEmpty()) {
manager.cancel(TAG_DIGEST, NOTIFICATION_ID)
return
}
ensureChannel()
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val today = LocalDate.now(zone)
val dayLabel = when (day) {
today -> context.getString(R.string.reminder_day_today)
today.plusDays(1) -> context.getString(R.string.reminder_day_tomorrow)
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(day)
}
val lines = listed.map { entry ->
val time = reminderTimeText(
beginMillis = entry.beginMillis,
endMillis = entry.endMillis,
isAllDay = entry.isAllDay,
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = day,
// Only cross-day spans name a date, so the week start never shows.
firstDayOfWeek = DayOfWeek.MONDAY,
todayLabel = context.getString(R.string.event_detail_all_day),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
context.getString(R.string.digest_line, time, entry.title.ifBlank { context.getString(R.string.event_untitled) })
}
val title = context.resources.getQuantityString(R.plurals.digest_title, listed.size, dayLabel, listed.size)
val style = NotificationCompat.InboxStyle().setBigContentTitle(title)
lines.take(MAX_LINES).forEach(style::addLine)
if (lines.size > MAX_LINES) {
val more = lines.size - MAX_LINES
style.setSummaryText(context.resources.getQuantityString(R.plurals.digest_more, more, more))
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(listed.joinToString(", ") { it.title.ifBlank { context.getString(R.string.event_untitled) } })
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setAutoCancel(true)
.setContentIntent(openDay(day))
.build()
try {
manager.notify(TAG_DIGEST, NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
Log.w(TAG, "Could not post the daily summary", e)
}
}
/** The summary's channel id, created if missing — for the settings deep-link. */
fun channelId(): String {
ensureChannel()
return CHANNEL_ID
}
private fun ensureChannel() {
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.digest_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = context.getString(R.string.digest_channel_description) },
)
}
private fun openDay(day: LocalDate): PendingIntent = PendingIntent.getActivity(
context,
REQUEST_CODE,
MainActivity.openDayIntent(context, day.toKotlinLocalDate()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private companion object {
const val TAG = "DailyDigestNotifier"
const val CHANNEL_ID = "daily_digest"
const val TAG_DIGEST = "daily_digest"
const val NOTIFICATION_ID = 2
const val REQUEST_CODE = 0xD16E
const val MAX_LINES = 6
}
}
@@ -8,7 +8,10 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.digestEntries
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.scheduleDigest
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
@@ -43,6 +46,7 @@ class ReminderScanner @Inject constructor(
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val digestNotifier: DailyDigestNotifier,
private val channels: ReminderChannels,
private val snoozes: ReminderSnoozeScheduler,
private val alarms: ReminderAlarmScheduler,
@@ -78,14 +82,22 @@ class ReminderScanner @Inject constructor(
val now = System.currentTimeMillis()
if (!hasReadCalendar()) return
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving
// so switching them back on doesn't replay the backlog.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
val lastFired = reminderWatermark(state.lastScanMillis(), now)
val horizon = now + MAX_ALARM_INTERVAL_MILLIS
val digestSettings = settingsPrefs.dailyDigest.first()
val nextDigest = if (digestSettings.enabled) postDigest(digestSettings, lastFired, now) else null
val nextReminder = if (settingsPrefs.remindersEnabled.first()) reminders(lastFired, now, horizon) else null
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog — and while both are off, so switching one
// back on doesn't replay it.
state.setLastScanMillis(now)
val next = listOfNotNull(nextReminder, nextDigest).minOrNull()
if (next == null) alarms.cancelScan() else alarms.scheduleScan(minOf(next, horizon))
}
/** Post the event reminders that are due; returns the next wake-up they need. */
private suspend fun reminders(lastFired: Long, now: Long, horizon: Long): Long {
syncChannels()
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach into the past too: an all-day "at time of event" encodes to a
@@ -104,9 +116,9 @@ class ReminderScanner @Inject constructor(
val schedule = scheduleReminders(
planned = planned,
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
lastFiredMillis = lastFired,
nowMillis = now,
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
horizonMillis = horizon,
)
if (notifier.canPost()) {
@@ -116,10 +128,28 @@ class ReminderScanner @Inject constructor(
notifier.post(alert)
}
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
return schedule.nextAlarmMillis
}
/**
* Post the daily summary if it is owed (#459); returns its next moment. It
* leaves out calendars excluded from it, not those muted for reminders —
* a calendar too noisy to ping per event can still belong in the overview.
*/
private suspend fun postDigest(settings: DailyDigestSettings, lastFired: Long, now: Long): Long {
val zone = ZoneId.systemDefault()
val schedule = scheduleDigest(settings.timeMinutes, settings.day, zone, lastFired, now)
val day = schedule.dueDay ?: return schedule.nextAlarmMillis
// A failed summary must not cost the event reminders this pass owes.
if (digestNotifier.canPost()) runCatching {
// A day either side: all-day occurrences sit at UTC midnight, which
// can fall outside the local day they stand for.
val from = day.minusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val to = day.plusDays(2).atStartOfDay(zone).toInstant().toEpochMilli()
val entries = digestEntries(source.occurrences(from, to), day, zone, settings.excludedCalendarIds)
digestNotifier.post(day, entries)
}.onFailure { Log.w(TAG, "Daily summary failed", it) }
return schedule.nextAlarmMillis
}
/**
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.reminders.splitTimedReminders
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -40,6 +42,11 @@ data class EventForm(
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
val reminders: List<Int> = emptyList(),
/**
* "N days before at HH:MM" reminders of a timed event (#459), written as lead
* times measured from [start] at save time. Always empty on an all-day event.
*/
val fixedReminders: List<FixedTimeReminder> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
/** Confirmed / Tentative / Cancelled (#388). */
@@ -286,7 +293,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (timezone != null) add(EventFormField.Timezone)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (reminders.isNotEmpty() || fixedReminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (status != EventStatus.Confirmed) add(EventFormField.Status)
@@ -334,3 +341,21 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
calendarId = instance.calendarId.takeIf { canModify },
status = status.forNewEvent(),
)
/**
* This form with its timed reminders read back into lead times and
* [EventForm.fixedReminders] ([splitTimedReminders]), the wall clock taken in
* [deviceZone]. All-day forms come back untouched.
*/
fun EventForm.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, deviceZone: TimeZone): EventForm {
if (isAllDay || reminders.isEmpty()) return this
val split = splitTimedReminders(reminders, startInstant(resolvedZone(deviceZone)), deviceZone, known)
return copy(reminders = split.leadMinutes, fixedReminders = (fixedReminders + split.fixed).distinct())
}
/** The offsets [EventForm.fixedReminders] store for this form's start; empty for an all-day form. */
fun EventForm.fixedReminderOffsets(deviceZone: TimeZone): List<Int> {
if (isAllDay || fixedReminders.isEmpty()) return emptyList()
val start = startInstant(resolvedZone(deviceZone))
return fixedReminders.map { it.offsetMinutes(start, deviceZone) }
}
@@ -0,0 +1,78 @@
package de.jeanlucmakiola.calendula.domain.reminders
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
/** Which day the daily summary (#459) lists, counted from the day it arrives. */
enum class DigestDay(val daysAhead: Long) { Today(0), Tomorrow(1) }
/** The daily summary's settings. Calendars in [excludedCalendarIds] are left out of it. */
data class DailyDigestSettings(
val enabled: Boolean = false,
val timeMinutes: Int = 1_200,
val day: DigestDay = DigestDay.Tomorrow,
val excludedCalendarIds: Set<Long> = emptySet(),
)
/**
* What one scan concluded for the summary: list [dueDay] now (null when nothing
* is owed), and wake up again at [nextAlarmMillis].
*/
data class DigestSchedule(
val dueDay: LocalDate?,
val nextAlarmMillis: Long,
)
/**
* When the summary is owed, measured against the same watermark as the event
* reminders: a moment in `(lastFiredMillis, nowMillis]` is due. Only the latest
* such moment counts — a phone off for three evenings gets one summary, not
* three — and only while the day it lists has not passed.
*/
fun scheduleDigest(
timeMinutes: Int,
day: DigestDay,
zone: ZoneId,
lastFiredMillis: Long,
nowMillis: Long,
): DigestSchedule {
val time = LocalTime.of(timeMinutes / 60, timeMinutes % 60)
val fireAt = { date: LocalDate -> date.atTime(time).atZone(zone).toInstant().toEpochMilli() }
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
val latestDate = if (fireAt(today) <= nowMillis) today else today.minusDays(1)
val listed = latestDate.plusDays(day.daysAhead)
val dueDay = listed.takeIf { fireAt(latestDate) > lastFiredMillis && !it.isBefore(today) }
return DigestSchedule(dueDay = dueDay, nextAlarmMillis = fireAt(latestDate.plusDays(1)))
}
/**
* The occurrences the summary for [day] lists: all-day ones covering it (their
* UTC dates), then timed ones overlapping it in [zone], each group in start
* order. Calendars in [excludedCalendarIds] are left out.
*/
fun digestEntries(
instances: List<ReminderEventInstance>,
day: LocalDate,
zone: ZoneId,
excludedCalendarIds: Set<Long>,
): List<ReminderEventInstance> {
val dayStart = day.atStartOfDay(zone).toInstant().toEpochMilli()
val dayEnd = day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val utcDay = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
return instances
.asSequence()
.filter { it.calendarId !in excludedCalendarIds }
.filter {
if (it.isAllDay) {
it.beginMillis <= utcDay && maxOf(it.endMillis, it.beginMillis + 1) > utcDay
} else {
it.beginMillis < dayEnd && maxOf(it.endMillis, it.beginMillis + 1) > dayStart
}
}
.distinctBy { occurrenceKey(it.eventId, it.beginMillis) }
.sortedWith(compareBy({ !it.isAllDay }, { it.beginMillis }, { it.title }))
.toList()
}
@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.domain.reminders
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* A timed event's reminder "[daysBefore] days before, at [minuteOfDay]" (#459).
*
* The provider only knows lead times, so it is stored as an ordinary `Reminders`
* row whose offset is the distance from that wall-clock moment to the start
* ([offsetMinutes]) — it fires on every device and in every app. The pattern is
* only known to Calendula while it can be read back ([splitTimedReminders]); the
* editor then re-derives the offset whenever the start moves.
*/
data class FixedTimeReminder(val daysBefore: Int, val minuteOfDay: Int) {
init {
require(daysBefore in 1..MAX_DAYS_BEFORE) { "daysBefore out of range: $daysBefore" }
require(minuteOfDay in 0 until MINUTES_PER_DAY) { "minuteOfDay out of range: $minuteOfDay" }
}
/** The moment this reminder names for an event starting at [start], read in [zone]. */
fun alarmAt(start: Instant, zone: TimeZone): Instant {
val day = start.toLocalDateTime(zone).date.minus(daysBefore, DateTimeUnit.DAY)
return LocalDateTime(day, LocalTime(minuteOfDay / 60, minuteOfDay % 60)).toInstant(zone)
}
/** The provider offset to store for an event starting at [start]. */
fun offsetMinutes(start: Instant, zone: TimeZone): Int =
(start - alarmAt(start, zone)).inWholeMinutes.toInt()
/** The stored form, `days@minuteOfDay`. */
fun toToken(): String = "$daysBefore$TOKEN_SEP$minuteOfDay"
companion object {
const val MAX_DAYS_BEFORE = 28
private const val MINUTES_PER_DAY = 1_440
private const val TOKEN_SEP = "@"
/** [toToken] read back; null for anything malformed or out of range. */
fun parse(token: String?): FixedTimeReminder? {
val parts = token?.trim()?.split(TOKEN_SEP) ?: return null
if (parts.size != 2) return null
val days = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
if (days !in 1..MAX_DAYS_BEFORE || minute !in 0 until MINUTES_PER_DAY) return null
return FixedTimeReminder(days, minute)
}
}
}
/**
* The "N days before at HH:MM" a raw offset lands on for an event starting at
* [start], or null when it fires on the start's own day (or after it), or
* further back than [FixedTimeReminder.MAX_DAYS_BEFORE].
*/
fun fixedTimeReminderAt(offsetMinutes: Int, start: Instant, zone: TimeZone): FixedTimeReminder? {
if (offsetMinutes <= 0) return null
val alarm = (start - offsetMinutes.minutes).toLocalDateTime(zone)
val startDate = start.toLocalDateTime(zone).date
val days = startDate.toEpochDays() - alarm.date.toEpochDays()
if (days !in 1..FixedTimeReminder.MAX_DAYS_BEFORE) return null
return FixedTimeReminder(days.toInt(), alarm.hour * 60 + alarm.minute)
}
/** A timed event's reminders, read back: plain lead times and the fixed-time ones. */
data class TimedReminders(
val leadMinutes: List<Int>,
val fixed: List<FixedTimeReminder>,
)
/**
* Sort a timed event's raw offsets into lead times and "N days before at HH:MM"
* reminders. The provider can't say which an offset was meant as, so:
*
* - one landing on a pattern in [known] (the configured defaults) is fixed-time;
* - otherwise one reaching back past the start's own day is fixed-time, unless it
* is a whole number of days or one of [plainLeadMinutes] — those read as the
* lead time they look like.
*
* The cost of guessing wrong is small: the reminder fires at the same moment
* either way, and only a later change of the start's time of day treats it
* differently. A hand-entered "3 hours before" on an event at 01:00 does read as
* "1 day before at 22:00".
*/
fun splitTimedReminders(
offsets: List<Int>,
start: Instant,
zone: TimeZone,
known: Set<FixedTimeReminder>,
plainLeadMinutes: Collection<Int> = PLAIN_LEAD_MINUTES,
): TimedReminders {
val leads = mutableListOf<Int>()
val fixed = mutableListOf<FixedTimeReminder>()
offsets.distinct().forEach { offset ->
val pattern = fixedTimeReminderAt(offset, start, zone)
val isFixed = pattern != null && (
pattern in known ||
(offset % MINUTES_PER_DAY != 0 && offset !in plainLeadMinutes)
)
if (isFixed) fixed += pattern!! else leads += offset
}
return TimedReminders(leads.sorted(), fixed.distinct().sortedWith(FIXED_ORDER))
}
/** Most days before first, then by time — the order they fire in. */
val FIXED_ORDER: Comparator<FixedTimeReminder> =
compareByDescending<FixedTimeReminder> { it.daysBefore }.thenBy { it.minuteOfDay }
/** Lead times offered as presets: always read as what they look like. */
private val PLAIN_LEAD_MINUTES = setOf(5, 10, 15, 30, 45, 60, 90, 120)
private const val MINUTES_PER_DAY = 1_440
@@ -0,0 +1,208 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.saveable.Saver
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.LocalTime
/** "1 day before at 20:00", in the app's 12/24-hour convention. */
@Composable
fun fixedTimeReminderLabel(reminder: FixedTimeReminder): String = stringResource(
R.string.reminder_fixed_time,
pluralStringResource(R.plurals.reminder_days, reminder.daysBefore, reminder.daysBefore),
formatMinuteOfDay(reminder.minuteOfDay, LocalUse24HourFormat.current, currentLocale()),
)
/**
* The "N days before at HH:MM" editor (#459): a day count and a time, read as
* one sentence, plus a confirm. Seeded from [initial]; [onConfirm] gets the
* finished reminder. [connected] gives it the shape that meets a row above it.
*/
@Composable
fun FixedTimeReminderEditor(
initial: FixedTimeReminder,
confirmLabel: String,
onConfirm: (FixedTimeReminder) -> Unit,
modifier: Modifier = Modifier,
connected: Boolean = true,
) {
var daysText by rememberSaveable { mutableStateOf(initial.daysBefore.toString()) }
var minuteOfDay by rememberSaveable { mutableStateOf(initial.minuteOfDay) }
var pickingTime by rememberSaveable { mutableStateOf(false) }
val days = daysText.toIntOrNull()?.takeIf { it in 1..FixedTimeReminder.MAX_DAYS_BEFORE }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = if (connected) {
RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp)
} else {
RoundedCornerShape(22.dp)
},
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = daysText, onValueChange = { daysText = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
Text(
text = pluralStringResource(R.plurals.reminder_fixed_days_before_at, days ?: 1),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
FilledTonalButton(onClick = { pickingTime = true }) {
Text(formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current, currentLocale()))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = days?.let { fixedTimeReminderLabel(FixedTimeReminder(it, minuteOfDay)) }
?: stringResource(R.string.reminder_fixed_days_range, FixedTimeReminder.MAX_DAYS_BEFORE),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
enabled = days != null,
onClick = { days?.let { onConfirm(FixedTimeReminder(it, minuteOfDay)) } },
) { Text(confirmLabel) }
}
}
}
if (pickingTime) {
TimePickerAlert(
initial = LocalTime(minuteOfDay / 60, minuteOfDay % 60),
onConfirm = {
minuteOfDay = it.hour * 60 + it.minute
pickingTime = false
},
onDismiss = { pickingTime = false },
)
}
}
/**
* Picker for a fixed-time default (#459), full-screen: off, or a reminder at a
* set time edited in place — and, on a per-calendar picker ([inheritedLabel]
* non-null), deferring to the global default. Applies live; the user leaves via
* back.
*/
@Composable
fun FixedTimeReminderPicker(
title: String,
selected: FixedTimeOverride,
onSelect: (FixedTimeOverride) -> Unit,
onDismiss: () -> Unit,
inheritedLabel: String? = null,
) {
var current by rememberSaveable(stateSaver = FixedTimeOverrideSaver) { mutableStateOf(selected) }
var editing by rememberSaveable { mutableStateOf(false) }
// Mirrors [selected] until the first pick, as ReminderDefaultPicker does.
var userEdited by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selected) {
if (!userEdited) current = selected
}
fun apply(choice: FixedTimeOverride) {
userEdited = true
current = choice
onSelect(choice)
}
val chosen = (current as? FixedTimeOverride.At)?.reminder
FullScreenPicker(title = title, onDismiss = onDismiss) {
GroupedHint(text = stringResource(R.string.settings_fixed_reminder_hint))
Spacer(Modifier.height(8.dp))
val rowCount = if (inheritedLabel != null) 3 else 2
if (inheritedLabel != null) {
GroupedRow(
title = stringResource(R.string.reminder_use_default),
summary = inheritedLabel,
position = Position.Top,
checked = current is FixedTimeOverride.Inherit,
onClick = { apply(FixedTimeOverride.Inherit) },
)
}
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (rowCount == 3) Position.Middle else Position.Top,
checked = current is FixedTimeOverride.Off,
onClick = { apply(FixedTimeOverride.Off) },
)
CustomExpandRow(
title = stringResource(R.string.reminder_fixed_at_set_time),
summary = chosen?.let { fixedTimeReminderLabel(it) },
position = Position.Bottom,
expanded = editing,
onExpandedChange = { editing = it },
checked = chosen != null,
) {
FixedTimeReminderEditor(
initial = chosen ?: DEFAULT_FIXED_TIME_REMINDER,
confirmLabel = stringResource(R.string.reminder_custom_set),
onConfirm = {
apply(FixedTimeOverride.At(it))
editing = false
},
)
}
}
}
/** What the editor offers first: the evening before. */
val DEFAULT_FIXED_TIME_REMINDER = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
private val FixedTimeOverrideSaver = Saver<FixedTimeOverride, String>(
save = {
when (it) {
FixedTimeOverride.Inherit -> "inherit"
FixedTimeOverride.Off -> "off"
is FixedTimeOverride.At -> it.reminder.toToken()
}
},
restore = {
when (it) {
"inherit" -> FixedTimeOverride.Inherit
"off" -> FixedTimeOverride.Off
else -> FixedTimeReminder.parse(it)?.let(FixedTimeOverride::At) ?: FixedTimeOverride.Off
}
},
)
@@ -23,9 +23,12 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -172,6 +175,7 @@ class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -457,7 +461,9 @@ class RescheduleViewModel @Inject constructor(
dropped
}
// Read fixed-time reminders out, so the move keeps them on their wall clock (#459).
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
.withFixedTimeRemindersRead(settingsPrefs.knownFixedTimeReminders.first(), zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
@@ -108,6 +108,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
@@ -532,7 +533,8 @@ private fun EventDetailContent(
.sortedBy { it.minutes }
.forEach { reminder ->
Text(
text = reminderLeadTimeLabel(reminder.minutes),
text = state.fixedReminders[reminder.minutes]?.let { fixedTimeReminderLabel(it) }
?: reminderLeadTimeLabel(reminder.minutes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp),
)
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
/**
* UI state for the event-detail screen (spec S4).
@@ -26,6 +27,10 @@ sealed interface EventDetailUiState {
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
/** The description as drawn — HTML rendered (#271); null when there is none. */
val description: RichText? = null,
/** Reminder offsets that read as "N days before at HH:MM" (#459), by raw minutes. */
val fixedReminders: Map<Int, FixedTimeReminder> = emptyMap(),
/** The fixed-time patterns the reminders were read against. */
val knownFixed: Set<FixedTimeReminder> = emptySet(),
) : EventDetailUiState
}
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.splitTimedReminders
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -46,6 +49,7 @@ class EventDetailViewModel @Inject constructor(
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
@@ -142,7 +146,9 @@ class EventDetailViewModel @Inject constructor(
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
val zone = zoneSource.current()
return loaded.detail.toDuplicateForm(loaded.canModify, zone)
.withFixedTimeRemindersRead(loaded.knownFixed, zone)
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -157,6 +163,16 @@ class EventDetailViewModel @Inject constructor(
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
val fixedReminders = if (corrected.instance.isAllDay) {
emptyMap()
} else {
val zone = zoneSource.current()
corrected.reminders.map { it.minutes }.distinct().mapNotNull { minutes ->
splitTimedReminders(listOf(minutes), corrected.instance.start, zone, knownFixed)
.fixed.firstOrNull()?.let { minutes to it }
}.toMap()
}
val description = corrected.description
?.let(::descriptionRichText)
?.takeIf { it.text.isNotBlank() }
@@ -173,6 +189,8 @@ class EventDetailViewModel @Inject constructor(
description = description?.text,
),
description = description,
fixedReminders = fixedReminders,
knownFixed = knownFixed,
)
} catch (e: CancellationException) {
throw e
@@ -125,6 +125,10 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.common.DEFAULT_FIXED_TIME_REMINDER
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderEditor
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -153,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
@@ -875,7 +880,9 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
RemindersSection(
reminders = form.reminders,
fixedReminders = form.fixedReminders,
onRemove = viewModel::removeReminder,
onRemoveFixed = viewModel::removeFixedReminder,
onAdd = { sheet = EditSheet.Reminder },
)
}
@@ -1284,9 +1291,25 @@ private fun LocationCard(
* connected corners, the last row being "Add reminder".
*/
@Composable
private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAdd: () -> Unit) {
val rows = reminders.size + 1
reminders.forEachIndexed { index, minutes ->
private fun RemindersSection(
reminders: List<Int>,
fixedReminders: List<FixedTimeReminder>,
onRemove: (Int) -> Unit,
onRemoveFixed: (FixedTimeReminder) -> Unit,
onAdd: () -> Unit,
) {
val rows = fixedReminders.size + reminders.size + 1
fixedReminders.forEachIndexed { index, reminder ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
title = fixedTimeReminderLabel(reminder),
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
onRemove = { onRemoveFixed(reminder) },
)
}
reminders.forEachIndexed { i, minutes ->
val index = fixedReminders.size + i
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
@@ -1297,7 +1320,7 @@ private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAd
}
AddActionCard(
label = stringResource(R.string.event_edit_add_reminder),
position = positionOf(reminders.size, rows),
position = positionOf(rows - 1, rows),
onClick = onAdd,
)
}
@@ -1489,10 +1512,15 @@ private fun EditSheetHost(
}
EditSheet.Reminder -> ReminderPickerDialog(
alreadyChosen = form.reminders,
offerFixedTime = !form.isAllDay,
onSelect = { minutes ->
viewModel.addReminder(minutes)
onClose()
},
onSelectFixed = { reminder ->
viewModel.addFixedReminder(reminder)
onClose()
},
onDismiss = onClose,
)
EditSheet.Recurrence -> RecurrencePickerDialog(
@@ -1581,10 +1609,13 @@ private fun FieldPickerDialog(
@Composable
private fun ReminderPickerDialog(
alreadyChosen: List<Int>,
offerFixedTime: Boolean,
onSelect: (Int) -> Unit,
onSelectFixed: (FixedTimeReminder) -> Unit,
onDismiss: () -> Unit,
) {
var customMode by rememberSaveable { mutableStateOf(false) }
var fixedMode by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val customMinutes = amountText.toIntOrNull()
@@ -1604,9 +1635,18 @@ private fun ReminderPickerDialog(
}
},
) {
if (!customMode) {
if (fixedMode) {
GroupedHint(text = stringResource(R.string.event_edit_reminder_fixed_hint))
Spacer(Modifier.height(8.dp))
FixedTimeReminderEditor(
initial = DEFAULT_FIXED_TIME_REMINDER,
confirmLabel = stringResource(R.string.event_edit_add),
onConfirm = onSelectFixed,
connected = false,
)
} else if (!customMode) {
val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen }
val rowCount = presets.size + 1
val rowCount = presets.size + if (offerFixedTime) 2 else 1
presets.forEachIndexed { index, minutes ->
GroupedRow(
title = reminderLeadTimeLabel(minutes),
@@ -1614,6 +1654,14 @@ private fun ReminderPickerDialog(
onClick = { onSelect(minutes) },
)
}
if (offerFixedTime) {
GroupedRow(
title = stringResource(R.string.reminder_fixed_at_set_time),
summary = stringResource(R.string.event_edit_reminder_fixed_summary),
position = positionOf(presets.size, rowCount),
onClick = { fixedMode = true },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rowCount - 1, rowCount),
@@ -13,7 +13,11 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultFixedTimeReminder
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.reminders.FIXED_ORDER
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
@@ -139,6 +143,7 @@ enum class ImportSource(internal val ownsDuration: Boolean) {
data class ImportReminderPrompt(
val currentReminderCount: Int,
val defaultReminders: List<Int>,
val defaultFixedReminders: List<FixedTimeReminder> = emptyList(),
)
/**
@@ -208,6 +213,8 @@ class EventEditViewModel @Inject constructor(
val zone: TimeZone,
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
val detail: EventDetail,
/** The fixed-time patterns its reminders were read against, so a re-read splits alike. */
val knownFixed: Set<FixedTimeReminder>,
) {
val original: EventForm get() = snapshot.form
}
@@ -225,7 +232,13 @@ class EventEditViewModel @Inject constructor(
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
val fixed: FixedTimeReminder?,
val fixedOverrides: Map<Long, FixedTimeReminder?>,
) {
/** The fixed-time reminders a new event on [calendarId] gets (#459); none on an all-day one. */
fun resolveFixedFor(calendarId: Long?, isAllDay: Boolean): List<FixedTimeReminder> =
if (isAllDay) emptyList() else listOfNotNull(resolveDefaultFixedTimeReminder(fixed, fixedOverrides, calendarId))
/** The default reminders for an event on [calendarId] of the given kind. */
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
timedGlobal = timed,
@@ -465,26 +478,27 @@ class EventEditViewModel @Inject constructor(
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
// The duration default resolves in parallel, so both must compose
// onto the current form, not a pre-suspend copy.
applyDefault { it.copy(reminders = reminders) }
applyDefault { it.copy(reminders = reminders, fixedReminders = fixed) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
if (reminders.isNotEmpty() || fixed.isNotEmpty()) {
_revealed.value = _revealed.value + EventFormField.Reminders
}
}
}
/** Snapshot the four settings-default reminder flows into one value. */
private suspend fun reminderDefaults(): ReminderDefaults = combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
/** Snapshot the settings-default reminder flows into one value. */
private suspend fun reminderDefaults(): ReminderDefaults = ReminderDefaults(
timed = settingsPrefs.defaultReminderMinutes.first(),
allDay = settingsPrefs.defaultAllDayReminderMinutes.first(),
timedOverrides = settingsPrefs.perCalendarReminderOverride.first(),
allDayOverrides = settingsPrefs.perCalendarAllDayReminderOverride.first(),
fixed = settingsPrefs.defaultFixedTimeReminder.first(),
fixedOverrides = settingsPrefs.perCalendarFixedTimeReminderOverride.first(),
)
/**
* Stretch a new timed event to the configured default length (#54) — the
@@ -531,15 +545,19 @@ class EventEditViewModel @Inject constructor(
val opened = session
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
val defaults = reminderDefaults()
val default = defaults.resolveFor(targetId, form.isAllDay)
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
// Bail if the form closed or became an edit while we resolved.
if (opened != session) return@launch
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
if (default.isEmpty() && fixed.isEmpty()) return@launch
if (default == current.reminders && fixed == current.fixedReminders) return@launch
_importReminderPrompt.value = ImportReminderPrompt(
currentReminderCount = current.reminders.size,
currentReminderCount = current.reminders.size + current.fixedReminders.size,
defaultReminders = default,
defaultFixedReminders = fixed,
)
}
}
@@ -549,7 +567,7 @@ class EventEditViewModel @Inject constructor(
val prompt = _importReminderPrompt.value ?: return
_importReminderPrompt.value = null
// Already frozen as touched by openImported; this just swaps the values.
edit { it.copy(reminders = prompt.defaultReminders) }
edit { it.copy(reminders = prompt.defaultReminders, fixedReminders = prompt.defaultFixedReminders) }
_revealed.value = _revealed.value + EventFormField.Reminders
}
@@ -579,14 +597,15 @@ class EventEditViewModel @Inject constructor(
if (opened == session) _loadFailed.value = true
return@launch
}
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
if (opened != session) return@launch
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
val snapshot = detail.toEditSnapshot(begin, end, zone).withFixedTimeRemindersRead(knownFixed, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end), knownFixed)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
open(snapshot.form)
@@ -639,7 +658,11 @@ class EventEditViewModel @Inject constructor(
// A kept pass of a repeated hour stays, so switching back out of all-day
// lands where the event was — unless dropping the pin changed the zone.
edit {
if (value) it.withTimezone(null, zoneSource.current()).copy(isAllDay = true) else it.copy(isAllDay = false)
if (value) {
it.withTimezone(null, zoneSource.current()).copy(isAllDay = true, fixedReminders = emptyList())
} else {
it.copy(isAllDay = false)
}
}
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
@@ -699,6 +722,19 @@ class EventEditViewModel @Inject constructor(
edit { it.copy(reminders = it.reminders - minutes) }
}
/** Add an "N days before at HH:MM" reminder (#459); timed events only. */
fun addFixedReminder(reminder: FixedTimeReminder) {
_remindersTouched.value = true
edit {
if (it.isAllDay) it else it.copy(fixedReminders = (it.fixedReminders + reminder).distinct().sortedWith(FIXED_ORDER))
}
}
fun removeFixedReminder(reminder: FixedTimeReminder) {
_remindersTouched.value = true
edit { it.copy(fixedReminders = it.fixedReminders - reminder) }
}
/**
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
* address just refreshes it (no duplicate row). A blank email is ignored —
@@ -815,6 +851,7 @@ class EventEditViewModel @Inject constructor(
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
.withFixedTimeRemindersRead(target.knownFixed, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
@@ -950,3 +987,7 @@ class EventEditViewModel @Inject constructor(
_form.value = _form.value?.let(block)
}
}
/** [EditSnapshot.form] with its timed reminders read back into lead times and fixed-time ones. */
private fun EditSnapshot.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, zone: TimeZone): EditSnapshot =
copy(form = form.withFixedTimeRemindersRead(known, zone))
@@ -33,7 +33,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.data.prefs.fixedTimeOverrideFor
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderPicker
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -47,6 +54,7 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -84,10 +92,24 @@ internal fun NotificationsScreen(
}
}
val toggleDigest: (Boolean) -> Unit = { enabled ->
viewModel.setDailyDigestEnabled(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var showFixedDefault by remember { mutableStateOf(false) }
var fixedOverrideFor by remember { mutableStateOf<Long?>(null) }
var showDigestDay by remember { mutableStateOf(false) }
var showDigestTime by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
@@ -127,6 +149,12 @@ internal fun NotificationsScreen(
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_fixed_reminder),
summary = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
position = Position.Middle,
onClick = { showFixedDefault = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
@@ -143,6 +171,19 @@ internal fun NotificationsScreen(
onClick = { showAllDayReminderTime = true },
)
Spacer(Modifier.height(24.dp))
DailyDigestRows(
digest = state.dailyDigest,
onEnable = { enabled ->
toggleDigest(enabled)
},
onPickDay = { showDigestDay = true },
onPickTime = { showDigestTime = true },
onOpenSound = {
scope.launch { viewModel.digestChannel()?.let { context.openNotificationSettings(it) } }
},
)
// Reliability is a soft, optional battery-optimisation exemption via
// a system-settings deep-link, shown as live status.
Spacer(Modifier.height(24.dp))
@@ -219,6 +260,8 @@ internal fun NotificationsScreen(
onOpenSound = { openCalendarSound(calendar.id) },
onOpenSpecialDates = onOpenSpecialDates,
onEditOverride = { overrideDialog = it },
onEditFixedOverride = { fixedOverrideFor = calendar.id },
onSetInDigest = { viewModel.setCalendarInDailyDigest(calendar.id, it) },
)
}
}
@@ -227,6 +270,43 @@ internal fun NotificationsScreen(
}
}
if (showFixedDefault) {
FixedTimeReminderPicker(
title = stringResource(R.string.settings_fixed_reminder),
selected = state.defaultFixedTimeReminder?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off,
onSelect = { viewModel.setDefaultFixedTimeReminder((it as? FixedTimeOverride.At)?.reminder) },
onDismiss = { showFixedDefault = false },
)
}
fixedOverrideFor?.let { calendarId ->
FixedTimeReminderPicker(
title = stringResource(R.string.settings_fixed_reminder),
selected = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendarId),
onSelect = { viewModel.setCalendarFixedTimeReminderOverride(calendarId, it) },
onDismiss = { fixedOverrideFor = null },
inheritedLabel = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
)
}
if (showDigestDay) {
OptionPicker(
title = stringResource(R.string.settings_digest_day),
options = DigestDay.entries,
selected = state.dailyDigest.day,
label = { stringResource(digestDayLabel(it)) },
onSelect = viewModel::setDailyDigestDay,
onDismiss = { showDigestDay = false },
)
}
if (showDigestTime) {
TimePickerAlert(
initial = LocalTime(state.dailyDigest.timeMinutes / 60, state.dailyDigest.timeMinutes % 60),
onConfirm = {
viewModel.setDailyDigestTimeMinutes(it.hour * 60 + it.minute)
showDigestTime = false
},
onDismiss = { showDigestTime = false },
)
}
if (showSnooze) {
DurationPicker(
title = stringResource(R.string.settings_snooze_duration),
@@ -320,9 +400,12 @@ private fun CalendarNotificationRows(
onOpenSound: () -> Unit,
onOpenSpecialDates: () -> Unit,
onEditOverride: (OverrideTarget) -> Unit,
onEditFixedOverride: () -> Unit,
onSetInDigest: (Boolean) -> Unit,
) {
val managed = calendar.id in state.managedCalendarIds
val takesOverrides = calendar.canModifyContents && !managed
val digestOn = state.dailyDigest.enabled
Column {
GroupedSwitchRow(
title = stringResource(R.string.settings_calendar_notifications),
@@ -334,10 +417,19 @@ private fun CalendarNotificationRows(
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(R.string.settings_calendar_notification_sound_hint),
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
position = if (managed || takesOverrides || digestOn) Position.Middle else Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
if (digestOn) {
GroupedSwitchRow(
title = stringResource(R.string.settings_calendar_in_digest),
checked = calendar.id !in state.dailyDigest.excludedCalendarIds,
onCheckedChange = onSetInDigest,
summary = stringResource(R.string.settings_calendar_in_digest_hint),
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
)
}
if (managed) {
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
@@ -360,6 +452,19 @@ private fun CalendarNotificationRows(
position = Position.Middle,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
)
GroupedRow(
title = stringResource(R.string.settings_fixed_reminder),
summary = when (val fixed = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendar.id)) {
FixedTimeOverride.Inherit -> stringResource(
R.string.settings_calendar_reminder_inherits,
fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
)
FixedTimeOverride.Off -> stringResource(R.string.reminder_none)
is FixedTimeOverride.At -> fixedTimeReminderLabel(fixed.reminder)
},
position = Position.Middle,
onClick = onEditFixedOverride,
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
@@ -371,6 +476,68 @@ private fun CalendarNotificationRows(
}
}
/**
* The daily summary (#459): its switch, and once on, which day it lists, when,
* and its channel's system settings.
*/
@Composable
private fun DailyDigestRows(
digest: DailyDigestSettings,
onEnable: (Boolean) -> Unit,
onPickDay: () -> Unit,
onPickTime: () -> Unit,
onOpenSound: () -> Unit,
) {
GroupedSwitchRow(
title = stringResource(R.string.settings_digest),
checked = digest.enabled,
onCheckedChange = onEnable,
summary = if (digest.enabled) {
stringResource(
R.string.settings_digest_summary,
stringResource(digestDayLabel(digest.day)),
settingsTimeOfDay(digest.timeMinutes),
)
} else {
stringResource(R.string.settings_digest_hint)
},
position = if (digest.enabled) Position.Top else Position.Alone,
)
AnimatedVisibility(visible = digest.enabled, enter = expandEnter(), exit = collapseExit()) {
Column {
GroupedRow(
title = stringResource(R.string.settings_digest_day),
summary = stringResource(digestDayLabel(digest.day)),
position = Position.Middle,
onClick = onPickDay,
)
GroupedRow(
title = stringResource(R.string.settings_digest_time),
summary = settingsTimeOfDay(digest.timeMinutes),
position = Position.Middle,
onClick = onPickTime,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = stringResource(R.string.settings_digest_sound_hint),
position = Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
}
}
}
private fun digestDayLabel(day: DigestDay): Int = when (day) {
DigestDay.Today -> R.string.settings_digest_day_today
DigestDay.Tomorrow -> R.string.settings_digest_day_tomorrow
}
/** A fixed-time default's label, or "None". */
@Composable
private fun fixedReminderChoiceLabel(reminder: FixedTimeReminder?): String =
reminder?.let { fixedTimeReminderLabel(it) } ?: stringResource(R.string.reminder_none)
@Composable
private fun ExternalLinkIcon() {
Icon(
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -96,6 +98,12 @@ data class SettingsUiState(
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** The "N days before at HH:MM" default for new timed events (#459); null = none. */
val defaultFixedTimeReminder: FixedTimeReminder? = null,
/** Per-calendar overrides of [defaultFixedTimeReminder]: absent inherits, null turns it off. */
val perCalendarFixedTimeReminderOverride: Map<Long, FixedTimeReminder?> = emptyMap(),
/** The daily summary notification (#459). */
val dailyDigest: DailyDigestSettings = DailyDigestSettings(),
/** Switched-on calendars you can edit: the per-calendar duration and reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
@@ -19,6 +19,11 @@ import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.data.prefs.dailyDigest
import de.jeanlucmakiola.calendula.data.reminders.DailyDigestNotifier
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
@@ -71,6 +76,7 @@ class SettingsViewModel @Inject constructor(
private val launcherNameManager: LauncherNameManager,
private val reminderScanner: ReminderScanner,
private val reminderChannels: ReminderChannels,
private val digestNotifier: DailyDigestNotifier,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -128,6 +134,9 @@ class SettingsViewModel @Inject constructor(
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
defaultFixedTimeReminder = this[SettingsKeys.defaultFixedTimeReminder],
perCalendarFixedTimeReminderOverride = this[SettingsKeys.perCalendarFixedTimeReminderOverride],
dailyDigest = dailyDigest,
writableCalendars = calendars.filter { it.canModifyContents },
reminderCalendars = calendars,
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
@@ -416,6 +425,32 @@ class SettingsViewModel @Inject constructor(
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = save { setAllowColorOnUnsupportedCalendars(enabled) }
fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) = save { setDefaultFixedTimeReminder(reminder) }
fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) =
save { setCalendarFixedTimeReminderOverride(calendarId, override) }
/** Every summary change re-plans, so the next one is armed for its new moment (#459). */
fun setDailyDigestEnabled(enabled: Boolean) = saveAndScan { setDailyDigestEnabled(enabled) }
fun setDailyDigestTimeMinutes(minutesOfDay: Int) = saveAndScan { setDailyDigestTimeMinutes(minutesOfDay) }
fun setDailyDigestDay(day: DigestDay) = saveAndScan { setDailyDigestDay(day) }
fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) = save { setCalendarInDailyDigest(calendarId, included) }
/** The summary's channel, created if needed so its system settings can open. */
suspend fun digestChannel(): String? = withContext(io) {
runCatching { digestNotifier.channelId() }.getOrNull()
}
private fun saveAndScan(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch {
prefs.write()
reminderScanner.scan()
}
}
private fun save(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch { prefs.write() }
}
+38
View File
@@ -739,6 +739,44 @@
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<!-- Reminders at a set time (#459). %1$s is a lead in days ("1 day before"), %2$s a clock time. -->
<string name="reminder_fixed_time">%1$s at %2$s</string>
<string name="reminder_fixed_at_set_time">At a set time</string>
<!-- Read as one sentence with the number field before it and a time after it: "[1] day before, at [20:00]". -->
<plurals name="reminder_fixed_days_before_at">
<item quantity="one">day before, at</item>
<item quantity="other">days before, at</item>
</plurals>
<string name="reminder_fixed_days_range">Pick 1 to %1$d days</string>
<string name="settings_fixed_reminder">Reminder at a set time</string>
<string name="event_edit_reminder_fixed_summary">E.g. the evening before</string>
<string name="event_edit_reminder_fixed_hint">Fires at this time however early or late the event starts, and moves along when you change the event\'s time.</string>
<string name="settings_fixed_reminder_hint">Added to new timed events, e.g. the evening before. It follows the event when you move it to another time.</string>
<!-- Daily summary notification (#459) -->
<string name="settings_digest">Daily summary</string>
<string name="settings_digest_hint">One notification listing a day\'s events</string>
<!-- %1$s is settings_digest_day_today/tomorrow, %2$s a clock time. -->
<string name="settings_digest_summary">%1$s at %2$s</string>
<string name="settings_digest_day">Lists</string>
<string name="settings_digest_day_today">Today\'s events</string>
<string name="settings_digest_day_tomorrow">Tomorrow\'s events</string>
<string name="settings_digest_time">Time</string>
<string name="settings_digest_sound_hint">Sound and pop-up for the summary, in system settings</string>
<string name="settings_calendar_in_digest">In daily summary</string>
<string name="settings_calendar_in_digest_hint">List this calendar\'s events in the daily summary</string>
<string name="digest_channel_name">Daily summary</string>
<string name="digest_channel_description">One notification listing a day\'s events</string>
<!-- %1$s is "Today", "Tomorrow" or a date; %2$d the number of events. -->
<plurals name="digest_title">
<item quantity="one">%1$s: %2$d event</item>
<item quantity="other">%1$s: %2$d events</item>
</plurals>
<!-- One event in the summary: %1$s its time ("09:00 – 10:00" or "All day"), %2$s its title. -->
<string name="digest_line">%1$s · %2$s</string>
<plurals name="digest_more">
<item quantity="one">+%d more</item>
<item quantity="other">+%d more</item>
</plurals>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
@@ -10,6 +10,9 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -718,4 +721,51 @@ class SettingsPrefsTest {
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
@Test
fun `fixed-time reminder defaults to off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
val evening = FixedTimeReminder(1, 1_200)
prefs.setDefaultFixedTimeReminder(evening)
assertThat(prefs.defaultFixedTimeReminder.first()).isEqualTo(evening)
prefs.setDefaultFixedTimeReminder(null)
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
}
@Test
fun `per-calendar fixed-time overrides keep inherit, off and a value apart`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
val morning = FixedTimeReminder(2, 480)
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
prefs.setCalendarFixedTimeReminderOverride(1L, FixedTimeOverride.Off)
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.At(morning))
val overrides = prefs.perCalendarFixedTimeReminderOverride.first()
assertThat(overrides.fixedTimeOverrideFor(1L)).isEqualTo(FixedTimeOverride.Off)
assertThat(overrides.fixedTimeOverrideFor(2L)).isEqualTo(FixedTimeOverride.At(morning))
assertThat(overrides.fixedTimeOverrideFor(3L)).isEqualTo(FixedTimeOverride.Inherit)
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 1L)).isNull()
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 3L))
.isEqualTo(FixedTimeReminder(1, 1_200))
assertThat(prefs.knownFixedTimeReminders.first()).containsExactly(FixedTimeReminder(1, 1_200), morning)
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Inherit)
assertThat(prefs.perCalendarFixedTimeReminderOverride.first().containsKey(2L)).isFalse()
}
@Test
fun `daily summary is off by default and its settings round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.dailyDigest.first()).isEqualTo(DailyDigestSettings())
prefs.setDailyDigestEnabled(true)
prefs.setDailyDigestTimeMinutes(420)
prefs.setDailyDigestDay(DigestDay.Today)
prefs.setCalendarInDailyDigest(5L, included = false)
assertThat(prefs.dailyDigest.first()).isEqualTo(
DailyDigestSettings(enabled = true, timeMinutes = 420, day = DigestDay.Today, excludedCalendarIds = setOf(5L)),
)
prefs.setCalendarInDailyDigest(5L, included = true)
assertThat(prefs.dailyDigest.first().excludedCalendarIds).isEmpty()
}
}
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class FixedTimeReminderFormTest {
private val zone = TimeZone.of("Europe/Berlin")
private val evening = FixedTimeReminder(1, 20 * 60)
private fun form(start: String, reminders: List<Int> = emptyList(), allDay: Boolean = false) = EventForm(
calendarId = 1,
isAllDay = allDay,
start = FormTime(LocalDateTime.parse(start)),
end = FormTime(LocalDateTime.parse(start)),
reminders = reminders,
)
@Test
fun `a moved start re-derives the offset so the wall clock stays`() {
val loaded = form("2026-06-10T10:00", reminders = listOf(10, 840))
.withFixedTimeRemindersRead(known = emptySet(), deviceZone = zone)
assertThat(loaded.reminders).containsExactly(10)
assertThat(loaded.fixedReminders).containsExactly(evening)
assertThat(loaded.fixedReminderOffsets(zone)).containsExactly(840)
val moved = loaded.copy(start = FormTime(LocalDateTime.parse("2026-06-10T15:30")))
assertThat(moved.fixedReminderOffsets(zone)).containsExactly(19 * 60 + 30)
}
@Test
fun `all-day forms keep their reminders and store no fixed ones`() {
val allDay = form("2026-06-10T00:00", reminders = listOf(840), allDay = true)
assertThat(allDay.withFixedTimeRemindersRead(emptySet(), zone)).isEqualTo(allDay)
assertThat(allDay.copy(fixedReminders = listOf(evening)).fixedReminderOffsets(zone)).isEmpty()
}
@Test
fun `fixed reminders count as a populated reminders section`() {
val withFixed = form("2026-06-10T10:00").copy(fixedReminders = listOf(evening))
assertThat(withFixed.populatedFields()).contains(EventFormField.Reminders)
}
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
class DailyDigestTest {
private val zone = ZoneId.of("Europe/Berlin")
private val minute = 60_000L
private val eight = 20 * 60
private fun millis(text: String): Long = LocalDateTime.parse(text).atZone(zone).toInstant().toEpochMilli()
@Test
fun `due once the moment passes the watermark`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T19:59"), millis("2026-06-10T20:00"))
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T20:00"))
}
@Test
fun `a second scan does not post again`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T20:00"), millis("2026-06-10T20:05"))
assertThat(schedule.dueDay).isNull()
}
@Test
fun `before the moment it only arms the alarm`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T08:00"), millis("2026-06-10T09:00"))
assertThat(schedule.dueDay).isNull()
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-10T20:00"))
}
@Test
fun `a late catch-up still lists a day that has not passed`() {
// Off overnight: last scan before 20:00, back on the next morning.
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T18:00"), millis("2026-06-11T07:00"))
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
}
@Test
fun `a today summary missed until the next day is dropped`() {
val seven = 7 * 60
val schedule = scheduleDigest(seven, DigestDay.Today, zone, millis("2026-06-10T06:00"), millis("2026-06-11T06:30"))
assertThat(schedule.dueDay).isNull()
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T07:00"))
}
@Test
fun `entries list all-day first, keep the day's own, and skip excluded calendars`() {
val day = LocalDate.of(2026, 6, 11)
val utcMidnight = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val allDay = instance(1, utcMidnight, utcMidnight + 24 * 60 * minute, isAllDay = true)
val previousAllDay = instance(2, utcMidnight - 24 * 60 * minute, utcMidnight, isAllDay = true)
val morning = instance(3, millis("2026-06-11T09:00"), millis("2026-06-11T10:00"))
val early = instance(4, millis("2026-06-11T07:00"), millis("2026-06-11T08:00"))
val nextDay = instance(5, millis("2026-06-12T00:00"), millis("2026-06-12T01:00"))
val excluded = instance(6, millis("2026-06-11T12:00"), millis("2026-06-11T13:00"), calendarId = 9)
val entries = digestEntries(
listOf(morning, previousAllDay, nextDay, early, allDay, excluded),
day,
zone,
excludedCalendarIds = setOf(9),
)
assertThat(entries.map { it.eventId }).containsExactly(1L, 4L, 3L).inOrder()
}
private fun instance(
eventId: Long,
begin: Long,
end: Long,
isAllDay: Boolean = false,
calendarId: Long = 1,
) = ReminderEventInstance(eventId, calendarId, begin, end, "Event $eventId", null, isAllDay)
}
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class FixedTimeReminderTest {
private val berlin = TimeZone.of("Europe/Berlin")
private val eveningBefore = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
private fun at(text: String, zone: TimeZone = berlin): Instant = LocalDateTime.parse(text).toInstant(zone)
@Test
fun `offset is the distance from the named wall clock to the start`() {
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T10:00"), berlin)).isEqualTo(14 * 60)
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T21:30"), berlin)).isEqualTo(25 * 60 + 30)
}
@Test
fun `offset spans a DST change in real minutes`() {
// Clocks go forward on 29 March 2026: 20:00 on the 28th to 10:00 on the 29th is 13 hours.
assertThat(eveningBefore.offsetMinutes(at("2026-03-29T10:00"), berlin)).isEqualTo(13 * 60)
}
@Test
fun `an offset reads back as the pattern it was written from`() {
val start = at("2026-03-29T10:00")
val offset = eveningBefore.offsetMinutes(start, berlin)
assertThat(fixedTimeReminderAt(offset, start, berlin)).isEqualTo(eveningBefore)
}
@Test
fun `a reminder on the start's own day or after it is no pattern`() {
val start = at("2026-06-10T10:00")
assertThat(fixedTimeReminderAt(60, start, berlin)).isNull()
assertThat(fixedTimeReminderAt(0, start, berlin)).isNull()
assertThat(fixedTimeReminderAt(-30, start, berlin)).isNull()
}
@Test
fun `split keeps presets and whole days as lead times`() {
val start = at("2026-06-10T10:00")
val split = splitTimedReminders(listOf(10, 1_440, 840, 2 * 1_440), start, berlin, known = emptySet())
assertThat(split.leadMinutes).containsExactly(10, 1_440, 2_880).inOrder()
assertThat(split.fixed).containsExactly(eveningBefore)
}
@Test
fun `a whole-day offset landing on a known pattern is fixed-time`() {
// An event at 20:00 with "1 day before at 20:00" stores exactly a day.
val start = at("2026-06-10T20:00")
assertThat(splitTimedReminders(listOf(1_440), start, berlin, known = emptySet()).fixed).isEmpty()
val split = splitTimedReminders(listOf(1_440), start, berlin, known = setOf(eveningBefore))
assertThat(split.fixed).containsExactly(eveningBefore)
assertThat(split.leadMinutes).isEmpty()
}
@Test
fun `fixed reminders sort by when they fire`() {
val start = at("2026-06-10T10:00")
val twoDays = FixedTimeReminder(2, 9 * 60)
val split = splitTimedReminders(
listOf(eveningBefore.offsetMinutes(start, berlin), twoDays.offsetMinutes(start, berlin)),
start,
berlin,
known = emptySet(),
)
assertThat(split.fixed).containsExactly(twoDays, eveningBefore).inOrder()
}
@Test
fun `token round-trips and rejects garbage`() {
assertThat(FixedTimeReminder.parse(eveningBefore.toToken())).isEqualTo(eveningBefore)
assertThat(FixedTimeReminder.parse("0@600")).isNull()
assertThat(FixedTimeReminder.parse("1@1440")).isNull()
assertThat(FixedTimeReminder.parse("x")).isNull()
assertThat(FixedTimeReminder.parse(null)).isNull()
}
}
@@ -92,12 +92,13 @@ class RescheduleViewModelTest {
io: CoroutineDispatcher = dispatcher,
): RescheduleViewModel {
fake.occurrencesAt(present)
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
fake, tempDir, io,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
settings = settings,
)
return RescheduleViewModel(repo, io, FakeZoneSource())
return RescheduleViewModel(repo, io, FakeZoneSource(), settings)
}
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
@@ -51,13 +51,14 @@ class EventDetailViewModelTest {
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
settings = settings,
)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource(), settings)
}
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
@@ -9,7 +9,11 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
@@ -719,4 +723,61 @@ class EventEditViewModelTest {
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
job.cancel()
}
@Test
fun `a new timed event gets the fixed-time default, and a calendar can turn it off`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
val evening = FixedTimeReminder(1, 1_200)
s.setDefaultFixedTimeReminder(evening)
s.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Off)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders).containsExactly(evening)
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders).isEmpty()
job.cancel()
}
@Test
fun `an event's set-time reminder opens as one and saves without a conflict`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val zone = FakeZoneSource().current()
val start = Instant.fromEpochMilliseconds(beginMillis)
val evening = FixedTimeReminder(1, 1_200)
val offset = evening.offsetMinutes(start, zone)
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = {
detail(calendarId = 1L).copy(reminders = listOf(Reminder(offset, ReminderMethod.Alert)))
}
}
val s = settings(tempDir)
s.setDefaultFixedTimeReminder(evening)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.fixedReminders).containsExactly(evening)
assertThat(form.reminders).isEmpty()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Renamed")
vm.save()
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
assertThat(fake.updatedEvents.single().third.fixedReminders).containsExactly(evening)
job.cancel()
}
}
+26
View File
@@ -409,6 +409,32 @@ off switch (#272): the scan plans nothing for those calendars (so they arm no
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
for snoozes.
**Reminders at a set time (#459).** "1 day before at 20:00" on a timed event
is an ordinary `Reminders` row whose offset is the distance from that wall-clock
moment (device zone) to the start, so it syncs and fires everywhere — the scan
plans it like any lead time. Only the editor knows it as a pattern
(`EventForm.fixedReminders`), and it re-derives the offset from the start it
saves with, so moving the event, in the form or by drag, keeps the wall clock.
Reading a row back is a guess, made in one place (`splitTimedReminders`): an
offset landing on a configured default's pattern is fixed-time; otherwise one
reaching back past the start's day is, unless it is a whole number of days or a
short preset. Guessing wrong never moves an alarm, only how a later time change
treats it. Limits: a recurring series stores one offset, so an occurrence whose
lead spans a DST change fires an hour off the named time (as in every other
app reading that row), and on another device or app the row is just a lead time.
New timed events get the default (`SettingsKeys.defaultFixedTimeReminder`, with
a per-calendar inherit/off/value override) like the lead-time defaults.
**The daily summary (#459)** rides the same scan and watermark: its moment is
due when it falls in `(lastScan, now]` (`scheduleDigest`), only the latest one
counts after a long gap, and it is dropped once the day it lists has passed.
It is independent of the event-reminder switch, posts on its own channel
(`DailyDigestNotifier`), lists switched-on calendars minus its own exclusions,
and replaces the previous summary — or withdraws it when the day is empty.
The per-calendar reminder mute does not apply: a calendar too noisy to ping per
event can still belong in the overview, and the summary has its own
per-calendar switch next to it.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
a latch that disables its own scheduling once a real broadcast arrives — cannot