From 7ab277b4340f5b4bdefae6f9ccbc3fea129c0934 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 22:21:01 +0200 Subject: [PATCH 1/2] feat(reminders): snooze + dismiss notification actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Snooze" and "Dismiss" action buttons to reminder notifications. The app is otherwise pure provider-broadcast (the Etar model): the calendar provider fires EVENT_REMINDER and we post the notification, then mark the CalendarAlerts row fired. A snoozed reminder has no provider backing — its row is already fired — so snooze self-schedules an exact alarm to re-show the same notification, while primary delivery is left unchanged. - ReminderActionReceiver (not exported): SNOOZE cancels + schedules a re-show, DISMISS cancels, SHOW (the alarm) re-posts so it can be snoozed or dismissed again. - ReminderSnoozeScheduler: setExactAndAllowWhileIdle, with an inexact allow-while-idle fallback if exact alarms are revoked (API 31-32). - ReminderNotifier: two actions + cancel(). - snoozeMinutes pref (default 10) in Settings -> Notifications, OptionPicker presets 5/10/15/30/60. - Manifest: USE_EXACT_ALARM + SCHEDULE_EXACT_ALARM (maxSdk 32) + receiver. - New ic_notification_snooze/_dismiss drawables, duration plurals, en+de strings, snooze-pref tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 18 +++ .../calendula/data/prefs/SettingsPrefs.kt | 17 +++ .../data/reminders/ReminderActionReceiver.kt | 124 ++++++++++++++++++ .../data/reminders/ReminderNotifier.kt | 23 ++++ .../data/reminders/ReminderSnoozeScheduler.kt | 55 ++++++++ .../calendula/ui/settings/SettingsScreen.kt | 33 +++++ .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 13 +- .../res/drawable/ic_notification_dismiss.xml | 12 ++ .../res/drawable/ic_notification_snooze.xml | 12 ++ app/src/main/res/values-de/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 12 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 17 +++ 13 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt create mode 100644 app/src/main/res/drawable/ic_notification_dismiss.xml create mode 100644 app/src/main/res/drawable/ic_notification_snooze.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c37243e..20737d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,17 @@ --> + + + + + + = store.data.map { prefs -> + (prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1) + } + + suspend fun setSnoozeMinutes(minutes: Int) { + store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) } + } + /** * Per-calendar overrides of [defaultReminderMinutes] for **timed** events, * keyed by calendar id. A calendar **present** in the map overrides the global @@ -243,6 +257,9 @@ 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 private const val MINUTES_PER_DAY = 1_440 + internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") + /** Default snooze delay for the notification "Snooze" action. */ + const val DEFAULT_SNOOZE_MINUTES = 10 internal val CALENDAR_REMINDER_OVERRIDE_KEY = stringPreferencesKey("per_calendar_reminder_override") internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt new file mode 100644 index 0000000..b0a57fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus + * the internal re-show when a snooze elapses. All three are app-internal + * intents (notification action buttons and our own [ReminderSnoozeScheduler] + * alarm), so the receiver is not exported. + * + * - **Dismiss** just cancels the notification — the `CalendarAlerts` row is + * already fired, so nothing re-posts it. + * - **Snooze** cancels the notification and schedules an exact alarm to re-show + * it after the user's snooze delay. + * - **Show** (the alarm) re-posts the same notification, so the user can snooze + * or dismiss it again. + */ +@AndroidEntryPoint +class ReminderActionReceiver : BroadcastReceiver() { + + @Inject lateinit var notifier: ReminderNotifier + @Inject lateinit var scheduler: ReminderSnoozeScheduler + @Inject lateinit var settingsPrefs: SettingsPrefs + + override fun onReceive(context: Context, intent: Intent) { + val alert = alertFrom(intent) ?: return + when (intent.action) { + ACTION_DISMISS -> notifier.cancel(alert) + + ACTION_SNOOZE -> { + // Cancel now so the notification doesn't linger until the alarm; + // the snooze delay read is the only async work. + notifier.cancel(alert) + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val minutes = settingsPrefs.snoozeMinutes.first() + val triggerAt = System.currentTimeMillis() + minutes * 60_000L + scheduler.schedule(alert, triggerAt) + } finally { + pendingResult.finish() + } + } + } + + ACTION_SHOW -> { + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) { + notifier.post(alert) + } + } finally { + pendingResult.finish() + } + } + } + } + } + + companion object { + const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE" + const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS" + const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW" + + private const val EXTRA_ALERT_ID = "alert_id" + private const val EXTRA_EVENT_ID = "event_id" + private const val EXTRA_BEGIN = "begin" + private const val EXTRA_END = "end" + private const val EXTRA_TITLE = "title" + private const val EXTRA_LOCATION = "location" + private const val EXTRA_ALL_DAY = "all_day" + + /** An explicit intent to this receiver carrying [alert] as extras. */ + fun intent(context: Context, action: String, alert: ReminderAlert): Intent = + Intent(context, ReminderActionReceiver::class.java).apply { + this.action = action + putExtra(EXTRA_ALERT_ID, alert.alertId) + putExtra(EXTRA_EVENT_ID, alert.eventId) + putExtra(EXTRA_BEGIN, alert.beginMillis) + putExtra(EXTRA_END, alert.endMillis) + putExtra(EXTRA_TITLE, alert.title) + putExtra(EXTRA_LOCATION, alert.location) + putExtra(EXTRA_ALL_DAY, alert.isAllDay) + } + + /** + * A stable request code per (alert, action) so the three PendingIntents + * of one notification stay distinct and don't clobber each other. + */ + fun requestCode(alert: ReminderAlert, action: String): Int { + val actionOffset = when (action) { + ACTION_SNOOZE -> 1 + ACTION_DISMISS -> 2 + ACTION_SHOW -> 3 + else -> 0 + } + return alert.alertId.toInt() * 8 + actionOffset + } + + private fun alertFrom(intent: Intent): ReminderAlert? { + if (!intent.hasExtra(EXTRA_ALERT_ID)) return null + return ReminderAlert( + alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), + eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), + beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), + endMillis = intent.getLongExtra(EXTRA_END, 0L), + title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), + location = intent.getStringExtra(EXTRA_LOCATION), + isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 5c60011..358d9ca 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor( .setAutoCancel(true) .setOnlyAlertOnce(true) .setContentIntent(detailIntent(alert)) + .addAction( + R.drawable.ic_notification_snooze, + context.getString(R.string.reminder_action_snooze), + actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE), + ) + .addAction( + R.drawable.ic_notification_dismiss, + context.getString(R.string.reminder_action_dismiss), + actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS), + ) .build() try { NotificationManagerCompat.from(context) @@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor( } } + /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ + fun cancel(alert: ReminderAlert) { + NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID) + } + + private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent = + PendingIntent.getBroadcast( + context, + ReminderActionReceiver.requestCode(alert, action), + ReminderActionReceiver.intent(context, action, alert), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( context, /* requestCode = */ alert.alertId.toInt(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt new file mode 100644 index 0000000..4bb78f5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Schedules a one-off exact alarm that re-shows a snoozed reminder. + * + * The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER` + * broadcast (the Etar model), but a snoozed reminder has no provider backing — + * its `CalendarAlerts` row is already fired — so we must re-fire it ourselves. + * A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall + * back to an inexact allow-while-idle alarm only if the OS withholds the + * exact-alarm capability (API 31–32 where the user revoked it). + */ +@Singleton +class ReminderSnoozeScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun schedule(alert: ReminderAlert, triggerAtMillis: Long) { + val alarmManager = context.getSystemService() ?: return + val pendingIntent = PendingIntent.getBroadcast( + context, + ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW), + ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + if (canScheduleExact(alarmManager)) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent, + ) + } else { + // Exact alarms revoked (API 31–32): an inexact wake is the honest + // best we can do without nagging for SCHEDULE_EXACT_ALARM. + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent, + ) + } + } + + /** + * True on API < 31 (no restriction), and on 31+ when the exact-alarm + * capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+ + * (Calendula is a calendar app), user-revocable on 31–32. + */ + private fun canScheduleExact(alarmManager: AlarmManager): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms() +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 4d1a06f..d5e8f63 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -546,6 +547,7 @@ private fun NotificationsScreen( 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 overrideDialog by remember { mutableStateOf(null) } var expandedCalendars by remember { mutableStateOf(emptySet()) } @@ -666,8 +668,27 @@ private fun NotificationsScreen( }, onClick = { openBatteryOptimizationSettings(context) }, ) + + // Snooze: how long the notification's "Snooze" action defers a reminder. + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_snooze_duration), + summary = snoozeDurationLabel(state.snoozeMinutes), + position = Position.Alone, + onClick = { showSnooze = true }, + ) } + if (showSnooze) { + OptionPicker( + title = stringResource(R.string.settings_snooze_duration), + options = SNOOZE_PRESETS, + selected = state.snoozeMinutes, + label = { snoozeDurationLabel(it) }, + onSelect = { viewModel.setSnoozeMinutes(it) }, + onDismiss = { showSnooze = false }, + ) + } if (showDefaultReminder) { ReminderDefaultPicker( title = stringResource(R.string.settings_default_reminder), @@ -792,6 +813,18 @@ private fun openBatteryOptimizationSettings(context: Context) { */ private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080) +/** Snooze delays offered for the notification "Snooze" action, in minutes. */ +private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) + +/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */ +@Composable +private fun snoozeDurationLabel(minutes: Int): String = + if (minutes % 60 == 0) { + pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60) + } else { + pluralStringResource(R.plurals.duration_minutes, minutes, minutes) + } + /** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { val time = Calendar.getInstance().apply { 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 245c296..5d88985 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 @@ -30,6 +30,8 @@ data class SettingsUiState( val defaultAllDayReminderMinutes: Int? = null, /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, + /** How long the notification "Snooze" action defers a reminder; default 10 min. */ + val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, /** * Per-calendar overrides of [defaultReminderMinutes] for timed events: a * calendar present in the map overrides the global default (null value = no 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 5ebeb2b..eead6f9 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 @@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor( prefs.defaultReminderMinutes, prefs.defaultAllDayReminderMinutes, prefs.allDayReminderTimeMinutes, - ) { allowColor, defaultReminder, allDayReminder, allDayReminderTime -> - ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime) + prefs.snoozeMinutes, + ) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze -> + ReminderDefaults( + allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze, + ) }, combine( prefs.perCalendarReminderOverride, @@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor( defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, allDayReminderTimeMinutes = defaults.allDayReminderTime, + snoozeMinutes = defaults.snoozeMinutes, perCalendarReminderOverride = overrides.timed, perCalendarAllDayReminderOverride = overrides.allDay, writableCalendars = overrides.calendars, @@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor( val defaultReminder: Int?, val allDayReminder: Int?, val allDayReminderTime: Int, + val snoozeMinutes: Int, ) private data class ReminderOverrides( @@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) } } + fun setSnoozeMinutes(minutes: Int) { + viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } + } + fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) { viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) } } diff --git a/app/src/main/res/drawable/ic_notification_dismiss.xml b/app/src/main/res/drawable/ic_notification_dismiss.xml new file mode 100644 index 0000000..81973d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_dismiss.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_notification_snooze.xml b/app/src/main/res/drawable/ic_notification_snooze.xml new file mode 100644 index 0000000..478352a --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_snooze.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4ec4c11..a6ee216 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -177,6 +177,15 @@ %d Woche vorher %d Wochen vorher + + + %d Minute + %d Minuten + + + %d Stunde + %d Stunden + (Ohne Titel) @@ -194,6 +203,8 @@ Der Schalter liegt in den Einstellungen unter Benachrichtigungen. Erinnerungen einschalten Später + Schlummern + Verwerfen Monat @@ -265,6 +276,7 @@ Zuverlässige Zustellung Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen. Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich. + Schlummerdauer Kalender Kalender verwalten Lokale Kalender anlegen, synchronisierte verwalten diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801091..28c40a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -178,6 +178,15 @@ %d week before %d weeks before + + + %d minute + %d minutes + + + %d hour + %d hours + (No title) @@ -195,6 +204,8 @@ The switch lives in Settings, under Notifications. Turn on reminders Not now + Snooze + Dismiss Month @@ -262,6 +273,7 @@ Reliable delivery Android may delay reminders to save battery. Exempt Calendula so they arrive on time. Exempt from battery optimisation — reminders arrive on time. + Snooze duration Calendars Manage calendars Create local calendars; manage synced ones 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 50ab6d8..dee5554 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 @@ -233,6 +233,23 @@ class SettingsPrefsTest { assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0) } + @Test + fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.snoozeMinutes.first()).isEqualTo(10) + } + + @Test + fun `snooze duration round-trips and clamps to at least one minute`( + @TempDir tempDir: Path, + ) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setSnoozeMinutes(30) + assertThat(prefs.snoozeMinutes.first()).isEqualTo(30) + prefs.setSnoozeMinutes(0) + assertThat(prefs.snoozeMinutes.first()).isEqualTo(1) + } + @Test fun `explicit week-start prefs resolve regardless of locale`() { assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) -- 2.49.1 From 62ebd48e3c776cec22b45027c7b1af148e1516f4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 22:13:36 +0200 Subject: [PATCH 2/2] docs(changelog): note snooze/dismiss reminder actions Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7f709..e1a51a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Snooze and dismiss buttons on reminder notifications. Dismiss clears the + reminder; snooze hides it and brings it back after a delay you pick in + Settings → Notifications (5 to 60 minutes, default 10). Android's calendar + system won't re-post a reminder on its own, so Calendula schedules an exact + alarm to bring a snoozed one back on time. + ## [2.7.5] — 2026-06-21 ### Changed -- 2.49.1