feat(reminders): snooze + dismiss notification actions
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) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,17 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||||
|
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||||
|
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||||
|
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||||
|
-->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -91,6 +102,13 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||||
|
re-show alarm. Not exported: only our own notification buttons and
|
||||||
|
AlarmManager PendingIntents target it. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.ReminderActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the notification "Snooze" action defers a reminder, in minutes.
|
||||||
|
* Tapping Snooze cancels the notification and re-posts it after this delay
|
||||||
|
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
|
||||||
|
* clamped to at least 1.
|
||||||
|
*/
|
||||||
|
val snoozeMinutes: Flow<Int> = 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,
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
* 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. */
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
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 =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(detailIntent(alert))
|
.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()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
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(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
|||||||
@@ -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<AlarmManager>() ?: 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()
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -546,6 +547,7 @@ private fun NotificationsScreen(
|
|||||||
var showDefaultReminder by remember { mutableStateOf(false) }
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
var showAllDayReminder by remember { mutableStateOf(false) }
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
@@ -666,8 +668,27 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { openBatteryOptimizationSettings(context) },
|
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) {
|
if (showDefaultReminder) {
|
||||||
ReminderDefaultPicker(
|
ReminderDefaultPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
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)
|
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"). */
|
/** 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 {
|
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||||
val time = Calendar.getInstance().apply {
|
val time = Calendar.getInstance().apply {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ data class SettingsUiState(
|
|||||||
val defaultAllDayReminderMinutes: Int? = null,
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
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
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
* calendar present in the map overrides the global default (null value = no
|
* calendar present in the map overrides the global default (null value = no
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.defaultReminderMinutes,
|
prefs.defaultReminderMinutes,
|
||||||
prefs.defaultAllDayReminderMinutes,
|
prefs.defaultAllDayReminderMinutes,
|
||||||
prefs.allDayReminderTimeMinutes,
|
prefs.allDayReminderTimeMinutes,
|
||||||
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
|
prefs.snoozeMinutes,
|
||||||
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
|
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
|
||||||
|
ReminderDefaults(
|
||||||
|
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.perCalendarReminderOverride,
|
prefs.perCalendarReminderOverride,
|
||||||
@@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
snoozeMinutes = defaults.snoozeMinutes,
|
||||||
perCalendarReminderOverride = overrides.timed,
|
perCalendarReminderOverride = overrides.timed,
|
||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
@@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val defaultReminder: Int?,
|
val defaultReminder: Int?,
|
||||||
val allDayReminder: Int?,
|
val allDayReminder: Int?,
|
||||||
val allDayReminderTime: Int,
|
val allDayReminderTime: Int,
|
||||||
|
val snoozeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderOverrides(
|
private data class ReminderOverrides(
|
||||||
@@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "close" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "snooze" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
|
||||||
|
</vector>
|
||||||
@@ -177,6 +177,15 @@
|
|||||||
<item quantity="one">%d Woche vorher</item>
|
<item quantity="one">%d Woche vorher</item>
|
||||||
<item quantity="other">%d Wochen vorher</item>
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d Minute</item>
|
||||||
|
<item quantity="other">%d Minuten</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d Stunde</item>
|
||||||
|
<item quantity="other">%d Stunden</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
@@ -194,6 +203,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||||
<string name="reminder_onboarding_skip_button">Später</string>
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
|
<string name="reminder_action_snooze">Schlummern</string>
|
||||||
|
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
@@ -265,6 +276,7 @@
|
|||||||
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
||||||
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
||||||
|
<string name="settings_snooze_duration">Schlummerdauer</string>
|
||||||
<string name="settings_section_calendars">Kalender</string>
|
<string name="settings_section_calendars">Kalender</string>
|
||||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||||
|
|||||||
@@ -178,6 +178,15 @@
|
|||||||
<item quantity="one">%d week before</item>
|
<item quantity="one">%d week before</item>
|
||||||
<item quantity="other">%d weeks before</item>
|
<item quantity="other">%d weeks before</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minute</item>
|
||||||
|
<item quantity="other">%d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hour</item>
|
||||||
|
<item quantity="other">%d hours</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
@@ -195,6 +204,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
<string name="reminder_action_snooze">Snooze</string>
|
||||||
|
<string name="reminder_action_dismiss">Dismiss</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
@@ -262,6 +273,7 @@
|
|||||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||||
|
<string name="settings_snooze_duration">Snooze duration</string>
|
||||||
<string name="settings_section_calendars">Calendars</string>
|
<string name="settings_section_calendars">Calendars</string>
|
||||||
<string name="settings_manage_calendars">Manage calendars</string>
|
<string name="settings_manage_calendars">Manage calendars</string>
|
||||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
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
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
Reference in New Issue
Block a user