diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 182a469d..93f810ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 7f96a33e..0e5c1be4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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 = - 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index bd98b6ab..dee44f99 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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>> = prefs.flow(SettingsKeys.perCalendarAllDayReminderOverride) + val defaultFixedTimeReminder: Flow = prefs.flow(SettingsKeys.defaultFixedTimeReminder) + val perCalendarFixedTimeReminderOverride: Flow> = + 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> = prefs.snapshot { + setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) + + it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull() + } + val dailyDigest: Flow = prefs.snapshot { it.dailyDigest } val autoBackupEnabled: Flow = prefs.flow(SettingsKeys.autoBackupEnabled) val autoBackupIntervalMinutes: Flow = prefs.flow(SettingsKeys.autoBackupIntervalMinutes) val autoBackupFolderUri: Flow = 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>> = 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 = + 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> = + 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 = booleanPref("daily_digest_enabled", false) + val dailyDigestTimeMinutes: Pref = + intPref("daily_digest_time_minutes", SettingsPrefs.DEFAULT_DIGEST_TIME).coerced { + it.coerceIn(0, SettingsPrefs.MINUTES_PER_DAY - 1) + } + val dailyDigestDay: Pref = enumPref("daily_digest_day", DigestDay.Tomorrow) + + /** Calendars left out of the daily summary; every other one is in. */ + val digestExcludedCalendarIds: Pref> = idSetPref("digest_excluded_calendar_ids") + /** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */ val autoBackupEnabled: Pref = 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.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, + calendarId: Long?, +): FixedTimeReminder? = + if (calendarId != null && overrides.containsKey(calendarId)) overrides[calendarId] else global + +private fun parseFixedTimeOverrides(stored: String?): Map = + 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.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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/DailyDigestNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/DailyDigestNotifier.kt new file mode 100644 index 00000000..483b7d9d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/DailyDigestNotifier.kt @@ -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) { + 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt index f50e2728..04e098ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt @@ -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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 3afbed56..ff98cde7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -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 = 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 = emptyList(), val availability: Availability = Availability.Busy, val accessLevel: AccessLevel = AccessLevel.Default, /** Confirmed / Tentative / Cancelled (#388). */ @@ -286,7 +293,7 @@ fun EventForm.populatedFields(): Set = 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, 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 { + if (isAllDay || fixedReminders.isEmpty()) return emptyList() + val start = startInstant(resolvedZone(deviceZone)) + return fixedReminders.map { it.offsetMinutes(start, deviceZone) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/DailyDigest.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/DailyDigest.kt new file mode 100644 index 00000000..658ecacb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/DailyDigest.kt @@ -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 = 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, + day: LocalDate, + zone: ZoneId, + excludedCalendarIds: Set, +): List { + 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/FixedTimeReminder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/FixedTimeReminder.kt new file mode 100644 index 00000000..fde11695 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/FixedTimeReminder.kt @@ -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, + val fixed: List, +) + +/** + * 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, + start: Instant, + zone: TimeZone, + known: Set, + plainLeadMinutes: Collection = PLAIN_LEAD_MINUTES, +): TimedReminders { + val leads = mutableListOf() + val fixed = mutableListOf() + 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 = + compareByDescending { 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/FixedTimeReminderUi.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/FixedTimeReminderUi.kt new file mode 100644 index 00000000..dc6d3a77 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/FixedTimeReminderUi.kt @@ -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( + 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 + } + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 5ef83d4a..82c510ae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -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(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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index fc723aca..434319e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt index 18c278cf..6fe0ecd3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -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 = emptyMap(), + /** The fixed-time patterns the reminders were read against. */ + val knownFixed: Set = emptySet(), ) : EventDetailUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 066ad4ad..5f860685 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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(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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 86e70e55..c24c4bbe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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, onRemove: (Int) -> Unit, onAdd: () -> Unit) { - val rows = reminders.size + 1 - reminders.forEachIndexed { index, minutes -> +private fun RemindersSection( + reminders: List, + fixedReminders: List, + 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, 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, + 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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 5235cb7b..a2253ab0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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, + val defaultFixedReminders: List = 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, ) { val original: EventForm get() = snapshot.form } @@ -225,7 +232,13 @@ class EventEditViewModel @Inject constructor( val allDay: List, val timedOverrides: Map>, val allDayOverrides: Map>, + val fixed: FixedTimeReminder?, + val fixedOverrides: Map, ) { + /** The fixed-time reminders a new event on [calendarId] gets (#459); none on an all-day one. */ + fun resolveFixedFor(calendarId: Long?, isAllDay: Boolean): List = + 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 = 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, zone: TimeZone): EditSnapshot = + copy(form = form.withFixedTimeRemindersRead(known, zone)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index b87b4b9c..98ad4ec4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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(null) } + var showDigestDay by remember { mutableStateOf(false) } + var showDigestTime by remember { mutableStateOf(false) } var overrideDialog by remember { mutableStateOf(null) } var calendarSectionExpanded by remember { mutableStateOf(false) } var expandedCalendars by remember { mutableStateOf(emptySet()) } @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 43a8edce..62365a91 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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> = emptyMap(), /** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */ val perCalendarAllDayReminderOverride: Map> = 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 = 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 = emptyList(), /** Switched-on calendars, each with its own notification switch and channel (#145, #272). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 5dae73ee..151b6625 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc92c56e..6188506f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -739,6 +739,44 @@ Sound, vibration and pop-ups for each calendar, in system settings Sound, vibration and pop-ups for this calendar, in system settings Default (%1$s) + + %1$s at %2$s + At a set time + + + day before, at + days before, at + + Pick 1 to %1$d days + Reminder at a set time + E.g. the evening before + Fires at this time however early or late the event starts, and moves along when you change the event\'s time. + Added to new timed events, e.g. the evening before. It follows the event when you move it to another time. + + Daily summary + One notification listing a day\'s events + + %1$s at %2$s + Lists + Today\'s events + Tomorrow\'s events + Time + Sound and pop-up for the summary, in system settings + In daily summary + List this calendar\'s events in the daily summary + Daily summary + One notification listing a day\'s events + + + %1$s: %2$d event + %1$s: %2$d events + + + %1$s · %2$s + + +%d more + +%d more + Reliable delivery Android may delay reminders to save battery. Exempt Calendula so they arrive on time. Exempt from battery optimisation — reminders arrive on time. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 1e113689..22d02732 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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() + } } + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/FixedTimeReminderFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/FixedTimeReminderFormTest.kt new file mode 100644 index 00000000..62e8079a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/FixedTimeReminderFormTest.kt @@ -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 = 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/DailyDigestTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/DailyDigestTest.kt new file mode 100644 index 00000000..30b73c26 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/DailyDigestTest.kt @@ -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) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/FixedTimeReminderTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/FixedTimeReminderTest.kt new file mode 100644 index 00000000..748135db --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/FixedTimeReminderTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index 86215696..41c8c6c2 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -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 = diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt index 4513eca5..7ec4a6f9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt @@ -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 { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index c763b5db..61480efb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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() + } } + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4822bd47..da1f8c9b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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