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
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 b7c3e75..302e67e 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)