Merge branch 'feat/per-calendar-multi-reminders' into feat/contact-special-dates

This commit is contained in:
2026-07-01 10:14:29 +02:00
21 changed files with 435 additions and 201 deletions

View File

@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
### Fixed
- Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden
everywhere else in the app ([#17]).
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
## [2.12.0] — 2026-06-28 ## [2.12.0] — 2026-06-28
### Added ### Added
@@ -729,3 +751,6 @@ automatically, with zero telemetry and no internet permission.
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10 [#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12 [#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13 [#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17

View File

@@ -90,6 +90,8 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
buildConfig = true
} }
// Don't embed AGP's dependency-metadata block in the APK signing block. It's // Don't embed AGP's dependency-metadata block in the APK signing block. It's

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
debug build paints the adaptive-icon background burnt orange instead, so the
debug icon reads at a glance as "not the real app" on the home screen. The
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
-->
<resources>
<color name="ic_launcher_background">#FFB23B00</color>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-build resource overrides. Merged on top of src/main for the `debug`
build type only (release/releaseTest keep the production values), so the
debug app is unmistakable on the launcher: its own label, alongside the
real app thanks to the `.debug` applicationId suffix.
-->
<resources>
<string name="app_name">Calendula Debug</string>
</resources>

View File

@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -29,6 +30,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
@@ -101,19 +103,24 @@ class MainActivity : AppCompatActivity() {
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
) { ) {
CompositionLocalProvider( Box(modifier = Modifier.fillMaxSize()) {
LocalUse24HourFormat provides use24Hour, CompositionLocalProvider(
LocalShowHourLines provides settings.showHourLines, LocalUse24HourFormat provides use24Hour,
) { LocalShowHourLines provides settings.showHourLines,
RootScreen( ) {
modifier = Modifier.fillMaxSize(), RootScreen(
requestedDetailKey = requestedDetailKey, modifier = Modifier.fillMaxSize(),
onDetailKeyConsumed = { requestedDetailKey = null }, requestedDetailKey = requestedDetailKey,
widgetNavRequest = requestedNav, onDetailKeyConsumed = { requestedDetailKey = null },
onWidgetNavConsumed = { requestedNav = null }, widgetNavRequest = requestedNav,
requestedImportUri = requestedImportUri, onWidgetNavConsumed = { requestedNav = null },
onImportConsumed = { requestedImportUri = null }, requestedImportUri = requestedImportUri,
) onImportConsumed = { requestedImportUri = null },
)
}
// A persistent corner marker so a debug build is never
// mistaken for the production app; compiled out of release.
if (BuildConfig.DEBUG) DebugRibbon()
} }
pendingCrashReport?.let { report -> pendingCrashReport?.let { report ->
CrashReportDialog( CrashReportDialog(

View File

@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
* provider clone the series row and apply these on top. Unlike the series * provider clone the series row and apply these on top. Unlike the series
* update there is no dirty check — the exception is a fresh row, so every * update there is no dirty check — the exception is a fresh row, so every
* form-backed column is written (empty optionals as explicit NULLs, since the * form-backed column is written (empty optionals as explicit NULLs, since the
* clone starts from the parent's values). An exception is a single event: * clone starts from the parent's values).
* DTEND, never RRULE/DURATION. *
* The occurrence's length travels as DURATION, never DTEND: the provider
* rejects DTEND on an exception outright (`CalendarProvider2`:
* "Exceptions can't overwrite dtend") and derives the instance end from
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
* "only this event" fail on-device (Codeberg #16).
*/ */
internal fun buildOccurrenceExceptionValues( internal fun buildOccurrenceExceptionValues(
form: EventForm, form: EventForm,
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.TITLE, form.title.trim()) put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis) put(CalendarContract.Events.DTSTART, times.dtStartMillis)
put(CalendarContract.Events.DTEND, times.dtEndMillis) put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())

View File

@@ -276,35 +276,36 @@ class SettingsPrefs @Inject constructor(
} }
/** /**
* The default reminder lead time (minutes before start) prefilled on new * The default reminder lead times (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept * **timed** events. The empty list = no default reminder — the prior
* as the factory default so existing users aren't surprised by reminders they * behaviour, kept as the factory default so existing users aren't surprised by
* never asked for. Stored as a string so "none" is distinct from a numeric * reminders they never asked for. Stored as a comma-joined list of minutes, or
* value (and from an unset key, which is also "none"). Per-calendar overrides * "none"/empty for no reminder (also the unset state). A legacy single value
* in [perCalendarReminderOverride] take precedence; all-day events instead use * ("30") parses transparently to a one-element list. Per-calendar overrides in
* [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/ */
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs -> val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes() prefs[DEFAULT_REMINDER_KEY].toReminderList()
} }
suspend fun setDefaultReminderMinutes(minutes: Int?) { suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE } store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
} }
/** /**
* The default reminder lead time prefilled on new **all-day** events, in * The default reminder lead times prefilled on new **all-day** events, in
* minutes before the start of the day. All-day events want day-scale lead * minutes before the start of the day. All-day events want day-scale lead
* times ("1 day before"), so they have their own default rather than reusing * times ("1 day before"), so they have their own default rather than reusing
* the timed one. `null` = no default. Per-calendar overrides do **not** apply * the timed one. Empty list = no default. Per-calendar overrides do **not**
* to all-day events — they always use this global value. * apply to all-day events — they always use this global value.
*/ */
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs -> val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes() prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
} }
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) { suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE } store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
} }
/** /**
@@ -341,13 +342,13 @@ class SettingsPrefs @Inject constructor(
/** /**
* 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
* timed default for its new events: a `null` value means "no reminder", an int * timed default for its new events: an **empty** list means "no reminder", a
* means that lead time. A calendar **absent** from the map inherits the global * non-empty list means those lead times. A calendar **absent** from the map
* default. Serialised as `id=value;id=value`, with `none` for an explicit * inherits the global default. Serialised as `id=value;id=value`, where a value
* no-reminder override. (All-day events ignore this and use * is a comma-joined minute list or `none` for an explicit no-reminder override.
* [defaultAllDayReminderMinutes].) * (All-day events ignore this and use [defaultAllDayReminderMinutes].)
*/ */
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs -> val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
} }
@@ -364,7 +365,7 @@ class SettingsPrefs @Inject constructor(
* events, with the same semantics as [perCalendarReminderOverride] (absent = * events, with the same semantics as [perCalendarReminderOverride] (absent =
* inherit the global all-day default; present null = no reminder). * inherit the global all-day default; present null = no reminder).
*/ */
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs -> val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
} }
@@ -495,43 +496,46 @@ sealed interface CalendarReminderOverride {
data object Inherit : CalendarReminderOverride data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */ /** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */ /** Specific lead times in minutes before the event start (non-empty). */
data class Minutes(val minutes: Int) : CalendarReminderOverride data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
} }
/** /**
* The lead time to prefill on a new event: the matching per-calendar override * 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 * if [calendarId] has one for this event kind, otherwise the global default for
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed * that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so * events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
* it can be unit-tested. * Pure so it can be unit-tested.
*/ */
fun resolveDefaultReminder( fun resolveDefaultReminder(
timedGlobal: Int?, timedGlobal: List<Int>,
allDayGlobal: Int?, allDayGlobal: List<Int>,
timedOverrides: Map<Long, Int?>, timedOverrides: Map<Long, List<Int>>,
allDayOverrides: Map<Long, Int?>, allDayOverrides: Map<Long, List<Int>>,
calendarId: Long?, calendarId: Long?,
isAllDay: Boolean, isAllDay: Boolean,
): Int? { ): List<Int> {
val overrides = if (isAllDay) allDayOverrides else timedOverrides val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) { return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides[calendarId] overrides.getValue(calendarId)
} else { } else {
global global
} }
} }
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */ /**
private fun MutableMap<Long, Int?>.applyOverride( * Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
* key; [None] and an empty [Minutes] both store the empty list).
*/
private fun MutableMap<Long, List<Int>>.applyOverride(
calendarId: Long, calendarId: Long,
override: CalendarReminderOverride, override: CalendarReminderOverride,
) { ) {
when (override) { when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId) CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null) CalendarReminderOverride.None -> put(calendarId, emptyList())
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes) is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
} }
} }
@@ -557,24 +561,36 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
private const val NONE = "none" private const val NONE = "none"
private const val ENTRY_SEP = ";" private const val ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "=" private const val KEY_VALUE_SEP = "="
private const val LIST_SEP = ","
private fun String?.toReminderMinutes(): Int? = when (this) { /** Distinct, ascending lead times — the canonical form stored and resolved. */
null, "", NONE -> null private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
else -> toIntOrNull()
/**
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
* one-element list. Non-numeric parts are dropped defensively.
*/
private fun String?.toReminderList(): List<Int> = when {
this == null || isEmpty() || this == NONE -> emptyList()
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
} }
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> { /** Serialise lead times for storage: "none" when empty, else comma-joined. */
private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
if (stored.isNullOrBlank()) return emptyMap() if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry -> return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null id to parts[1].toReminderList()
id to value
}.toMap() }.toMap()
} }
private fun serializeReminderOverrides(map: Map<Long, Int?>): String = private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" } map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E = private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
import android.provider.CalendarContract import android.provider.CalendarContract
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app (mirroring the
* event filtering in CalendarRepositoryImpl). The caller still marks the full
* due set fired, so suppressed alerts are not re-broadcast by the provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
/** /**
* Becomes the app that turns the calendar provider's reminder alarms into * Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts * visible notifications (the Etar model — the provider broadcasts
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore @Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier @Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs @Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
if (settingsPrefs.remindersEnabled.first()) { if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now) val due = alertStore.dueAlerts(now)
due.forEach { notifier.post(it) } val disabled = calendarPrefs.disabledCalendarIds.first()
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones.
postableAlerts(due, disabled).forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now) alertStore.markFired(due.map { it.alertId }, now)
} }
} finally { } finally {

View File

@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_ALERT_ID = "alert_id" private const val EXTRA_ALERT_ID = "alert_id"
private const val EXTRA_EVENT_ID = "event_id" private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_CALENDAR_ID = "calendar_id"
private const val EXTRA_BEGIN = "begin" private const val EXTRA_BEGIN = "begin"
private const val EXTRA_END = "end" private const val EXTRA_END = "end"
private const val EXTRA_TITLE = "title" private const val EXTRA_TITLE = "title"
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
this.action = action this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId) putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
putExtra(EXTRA_BEGIN, alert.beginMillis) putExtra(EXTRA_BEGIN, alert.beginMillis)
putExtra(EXTRA_END, alert.endMillis) putExtra(EXTRA_END, alert.endMillis)
putExtra(EXTRA_TITLE, alert.title) putExtra(EXTRA_TITLE, alert.title)
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
return ReminderAlert( return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
endMillis = intent.getLongExtra(EXTRA_END, 0L), endMillis = intent.getLongExtra(EXTRA_END, 0L),
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),

View File

@@ -16,6 +16,7 @@ import javax.inject.Singleton
data class ReminderAlert( data class ReminderAlert(
val alertId: Long, val alertId: Long,
val eventId: Long, val eventId: Long,
val calendarId: Long,
val beginMillis: Long, val beginMillis: Long,
val endMillis: Long, val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */ /** Raw event title; may be blank — the notifier substitutes "(no title)". */
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
ReminderAlert( ReminderAlert(
alertId = c.getLong(0), alertId = c.getLong(0),
eventId = c.getLong(1), eventId = c.getLong(1),
beginMillis = c.getLong(2), calendarId = c.getLong(2),
endMillis = c.getLong(3), beginMillis = c.getLong(3),
title = c.getString(4).orEmpty(), endMillis = c.getLong(4),
location = c.getString(5)?.takeIf { it.isNotBlank() }, title = c.getString(5).orEmpty(),
isAllDay = c.getInt(6) == 1, location = c.getString(6)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(7) == 1,
), ),
) )
} }
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
val PROJECTION = arrayOf( val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID, CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID, CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.CALENDAR_ID,
CalendarContract.CalendarAlerts.BEGIN, CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END, CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE, CalendarContract.CalendarAlerts.TITLE,

View File

@@ -0,0 +1,50 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
/**
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
* the app. Deliberately a stark, un-themed marker (not a product component) so a
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
* call site so it never reaches a release build. Non-interactive: it's a plain
* label with no pointer handler, so taps fall through to whatever is beneath it.
*
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
* so it overlays the UI.
*/
@Composable
fun BoxScope.DebugRibbon() {
Text(
text = "DEBUG",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopEnd)
.zIndex(1f)
// Push the band out so its midline crosses the very corner, then
// rotate it to the classic 45° ribbon.
.offset(x = 36.dp, y = 24.dp)
.rotate(45f)
.background(Color(0xFFB23B00))
.width(140.dp)
.padding(vertical = 2.dp),
)
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -111,10 +113,15 @@ fun <T> OptionPicker(
} }
/** /**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use * Reminder-default picker, full-screen and **multi-select**: each [presets]
* default reminder" row and a "None" row), the [presets] as lead-time rows, and * lead time (plus any chosen custom value) is a checkbox row that toggles
* a "Custom" row that expands an inline number field plus a segmented unit * independently, so a default can carry several reminders ("1 week before" *and*
* selector. Returns the choice as a [CalendarReminderOverride]. * "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
* default reminder" row above the list; choosing it defers to the global default
* and clears the checked times. With nothing checked and inherit off, the choice
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
* expands an inline number field plus a unit selector to add an arbitrary lead
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
*/ */
@Composable @Composable
fun ReminderDefaultPicker( fun ReminderDefaultPicker(
@@ -125,57 +132,74 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit, onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes // Optimistic local state: the chosen override is authoritative while the
val customSelected = selectedMinutes != null && selectedMinutes !in presets // picker is open, so quick successive toggles compose on each other instead
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) // of racing the round-trip through the settings flow (which would drop a
// toggle made before the previous write echoes back). Seeded once on open;
// the picker leaves composition on dismiss, so reopening re-reads [selected].
var current by remember { mutableStateOf(selected) }
val inherits = current is CalendarReminderOverride.Inherit
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
// Presets plus any selected custom (non-preset) values, each as its own row.
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
var customExpanded by rememberSaveable { mutableStateOf(false) } var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) } var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(seed.second) } var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val options = buildList { fun apply(override: CalendarReminderOverride) {
if (allowInherit) add(CalendarReminderOverride.Inherit) current = override
add(CalendarReminderOverride.None) onSelect(override)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
} }
val rowCount = options.size + 1 // + the custom row // Emit the override for a new set of minutes: empty collapses to None, which
// (with inherit unchosen) reads as an explicit "no reminder".
fun emit(minutes: List<Int>) {
val norm = minutes.distinct().sorted()
apply(
if (norm.isEmpty()) {
CalendarReminderOverride.None
} else {
CalendarReminderOverride.Minutes(norm)
},
)
}
fun toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
FullScreenPicker(title = title, onDismiss = onDismiss) { FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option -> // Exclusive "use default" choice (per-calendar only): its own group above
val isSelected = option == selected // the multi-select list, since inheriting is mutually exclusive with
// picking specific times.
if (allowInherit) {
GroupedRow( GroupedRow(
title = reminderOverrideLabel(option), title = stringResource(R.string.reminder_use_default),
position = positionOf(index, rowCount), position = Position.Alone,
selected = isSelected, selected = inherits,
trailing = if (isSelected) { trailing = if (inherits) {
{ SelectedCheck() } { SelectedCheck() }
} else { } else {
null null
}, },
onClick = { onClick = { apply(CalendarReminderOverride.Inherit) },
onSelect(option) )
onDismiss() Spacer(Modifier.height(24.dp))
}, }
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
position = positionOf(index, rowCount),
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
onClick = { toggle(minute) },
) )
} }
// When expanded, the Custom row connects downward into the editor card // When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern). // so the two read as one grouped container (the per-calendar pattern).
GroupedRow( GroupedRow(
title = if (customSelected) { title = stringResource(R.string.event_edit_reminder_custom),
stringResource( position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
R.string.reminder_custom_with_value,
reminderLeadTimeLabel(selectedMinutes!!),
)
} else {
stringResource(R.string.event_edit_reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded }, onClick = { customExpanded = !customExpanded },
) )
AnimatedVisibility( AnimatedVisibility(
@@ -189,8 +213,9 @@ fun ReminderDefaultPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
onConfirm = { minutes -> onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes)) emit(selectedMinutes + minutes)
onDismiss() amountText = ""
customExpanded = false
}, },
) )
} }
@@ -428,18 +453,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
} }
@Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
}

View File

@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
) )
private data class ReminderDefaults( private data class ReminderDefaults(
val timed: Int?, val timed: List<Int>,
val allDay: Int?, val allDay: List<Int>,
val timedOverrides: Map<Long, Int?>, val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, Int?>, val allDayOverrides: Map<Long, List<Int>>,
) )
private data class ExternalInputs( private data class ExternalInputs(
@@ -260,7 +260,7 @@ class EventEditViewModel @Inject constructor(
// Re-check after suspending: bail if the form closed or the user edited. // Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch
val default = resolveDefaultReminder( val reminders = resolveDefaultReminder(
timedGlobal = defaults.timed, timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay, allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides, timedOverrides = defaults.timedOverrides,
@@ -268,7 +268,6 @@ class EventEditViewModel @Inject constructor(
calendarId = targetId, calendarId = targetId,
isAllDay = form.isAllDay, isAllDay = form.isAllDay,
) )
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders) _form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and // Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field. // removable, even when Reminders isn't a default-shown field.

View File

@@ -969,7 +969,7 @@ private fun NotificationsScreen(
presets = REMINDER_PRESETS, presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(), selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false, allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) }, onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onDismiss = { showDefaultReminder = false }, onDismiss = { showDefaultReminder = false },
) )
} }
@@ -979,7 +979,7 @@ private fun NotificationsScreen(
presets = ALLDAY_REMINDER_PRESETS, presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(), selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false, allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) }, onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onDismiss = { showAllDayReminder = false }, onDismiss = { showAllDayReminder = false },
) )
} }
@@ -1028,13 +1028,13 @@ private fun NotificationsScreen(
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */ /** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (null = none) as a picker choice for selection highlighting. */ /** A global default (empty = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride = private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */ /** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesOrNull(): Int? = private fun CalendarReminderOverride.toMinutesList(): List<Int> =
(this as? CalendarReminderOverride.Minutes)?.minutes (this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
/** /**
* Whether Calendula is exempt from battery optimisation, re-read on every * Whether Calendula is exempt from battery optimisation, re-read on every
@@ -1109,27 +1109,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
} }
/** The stored override for [calendarId], as a picker choice (absent → inherit). */ /** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when { private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit !containsKey(calendarId) -> CalendarReminderOverride.Inherit
this[calendarId] == null -> CalendarReminderOverride.None this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!) else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
} }
/** Label for a global-default choice: null → "None", else the lead time. */ /** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable @Composable
private fun reminderChoiceLabel(minutes: Int?): String = private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes) if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
// than a (non-composable) joinToString transform.
val parts = ArrayList<String>(minutes.size)
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
return parts.joinToString(", ")
}
/** Row summary for a calendar: its override, or the inherited global default. */ /** Row summary for a calendar: its override, or the inherited global default. */
@Composable @Composable
private fun calendarOverrideSummary( private fun calendarOverrideSummary(
choice: CalendarReminderOverride, choice: CalendarReminderOverride,
globalDefault: Int?, globalDefault: List<Int>,
): String = when (choice) { ): String = when (choice) {
CalendarReminderOverride.Inherit -> CalendarReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
CalendarReminderOverride.None -> stringResource(R.string.reminder_none) CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -44,24 +44,24 @@ data class SettingsUiState(
/** Whether Calendula posts reminder notifications (v1.4). */ /** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** /**
* The default reminder lead time (minutes) prefilled on new timed events; * The default reminder lead times (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence. * empty = no default reminder. Per-calendar overrides take precedence.
*/ */
val defaultReminderMinutes: Int? = null, val defaultReminderMinutes: List<Int> = emptyList(),
/** The default reminder lead time prefilled on new all-day events; null = none. */ /** The default reminder lead times prefilled on new all-day events; empty = none. */
val defaultAllDayReminderMinutes: Int? = null, val defaultAllDayReminderMinutes: List<Int> = emptyList(),
/** 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. */ /** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, 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 (empty value = no
* reminder); absent = inherit the global default. * reminder); absent = inherit the global default.
*/ */
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(), val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */ /** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(), val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */ /** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(), val writableCalendars: List<CalendarSource> = emptyList(),
/** /**

View File

@@ -139,15 +139,15 @@ class SettingsViewModel @Inject constructor(
private data class ReminderDefaults( private data class ReminderDefaults(
val allowColor: Boolean, val allowColor: Boolean,
val defaultReminder: Int?, val defaultReminder: List<Int>,
val allDayReminder: Int?, val allDayReminder: List<Int>,
val allDayReminderTime: Int, val allDayReminderTime: Int,
val snoozeMinutes: Int, val snoozeMinutes: Int,
) )
private data class ReminderOverrides( private data class ReminderOverrides(
val timed: Map<Long, Int?>, val timed: Map<Long, List<Int>>,
val allDay: Map<Long, Int?>, val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
) )
@@ -255,11 +255,11 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) } viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
} }
fun setDefaultReminderMinutes(minutes: Int?) { fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
} }
fun setDefaultAllDayReminderMinutes(minutes: Int?) { fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
} }

View File

@@ -316,7 +316,6 @@
<string name="reminder_none">Keine</string> <string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string> <string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</string> <string name="reminder_custom_amount">Anzahl</string>
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
<string name="reminder_custom_set">Übernehmen</string> <string name="reminder_custom_set">Übernehmen</string>
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string> <string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string> <string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>

View File

@@ -324,7 +324,6 @@
<string name="reminder_none">None</string> <string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string> <string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string> <string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string> <string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string> <string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string> <string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>

View File

@@ -199,11 +199,12 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin") assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]) // The occurrence's length travels as DURATION, never DTEND — the provider
.isEqualTo(1_781_164_800_000L + 5_400_000L) // rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
// A single occurrence never carries its own rule. // derives the end from DTSTART + DURATION, clearing the inherited RRULE.
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
} }
@Test @Test

View File

@@ -183,22 +183,38 @@ class SettingsPrefsTest {
} }
@Test @Test
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest { fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isNull() assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest { fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDefaultReminderMinutes(30) prefs.setDefaultReminderMinutes(listOf(30))
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30) assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
prefs.setDefaultReminderMinutes(null) // Several lead times persist, normalised to distinct ascending order.
assertThat(prefs.defaultReminderMinutes.first()).isNull() prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
prefs.setDefaultReminderMinutes(emptyList())
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest { fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// The pre-multi-reminder format stored a bare integer string.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
m
}
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
}
@Test
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir) val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store) val prefs = SettingsPrefs(store)
store.updateData { p -> store.updateData { p ->
@@ -206,7 +222,7 @@ class SettingsPrefsTest {
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m m
} }
assertThat(prefs.defaultReminderMinutes.first()).isNull() assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
@@ -214,16 +230,16 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty() assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map -> prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15, 9L, null) assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
} }
// Inherit drops the override entirely (absent != null value). // Inherit drops the override entirely (absent != an empty-list value).
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map -> prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15) assertThat(map).containsExactly(7L, listOf(15, 10_080))
assertThat(map.containsKey(9L)).isFalse() assertThat(map.containsKey(9L)).isFalse()
} }
} }
@@ -231,48 +247,48 @@ class SettingsPrefsTest {
@Test @Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest { fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
prefs.setDefaultAllDayReminderMinutes(1_440) prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440) assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
prefs.setDefaultAllDayReminderMinutes(null) prefs.setDefaultAllDayReminderMinutes(emptyList())
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest { fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440)) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440) assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
// Clearing the all-day override leaves the timed one untouched. // Clearing the all-day override leaves the timed one untouched.
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty() assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
} }
@Test @Test
fun `resolveDefaultReminder picks the kind-matching override or global`() { fun `resolveDefaultReminder picks the kind-matching override or global`() {
val timed = mapOf(7L to 15, 9L to null) val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
val allDay = mapOf(7L to 2_880) val allDay = mapOf(7L to listOf(2_880))
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder( fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = 30, timedGlobal = listOf(30),
allDayGlobal = 1_440, allDayGlobal = listOf(1_440),
timedOverrides = timed, timedOverrides = timed,
allDayOverrides = allDay, allDayOverrides = allDay,
calendarId = calendarId, calendarId = calendarId,
isAllDay = isAllDay, isAllDay = isAllDay,
) )
// Timed: minutes override, explicit none, inherit global, no calendar. // Timed: multi-value override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15) assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
assertThat(resolve(9L, isAllDay = false)).isNull() assertThat(resolve(9L, isAllDay = false)).isEmpty()
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30) assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
assertThat(resolve(null, isAllDay = false)).isEqualTo(30) assertThat(resolve(null, isAllDay = false)).containsExactly(30)
// All-day: its own override wins; absent → all-day global; a timed-only // All-day: its own override wins; absent → all-day global; a timed-only
// override (cal 9) does not bleed into all-day. // override (cal 9) does not bleed into all-day.
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880) assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440) assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440) assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
} }
@Test @Test

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class PostableAlertsTest {
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = 0L,
title = "Event $alertId",
location = null,
isAllDay = false,
)
@Test
fun `keeps alerts when no calendar is disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
assertThat(postable).isEqualTo(due)
}
@Test
fun `drops alerts for a disabled calendar`() {
val keep = alert(1, calendarId = 100)
val drop = alert(2, calendarId = 200)
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
assertThat(postable).containsExactly(keep)
}
@Test
fun `drops every alert when all their calendars are disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
assertThat(postable).isEmpty()
}
@Test
fun `keeps multiple alerts from the same enabled calendar`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
assertThat(postable).isEqualTo(due)
}
}