Merge branch 'feat/per-calendar-multi-reminders' into feat/contact-special-dates
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal 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>
|
||||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal 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>
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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(),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user