Compare commits

..

1 Commits

Author SHA1 Message Date
47e9acc43a chore(deps): update gradle to v9.6.1
Some checks failed
CI / ci (pull_request) Failing after 1m33s
2026-06-29 05:01:52 +00:00
17 changed files with 196 additions and 327 deletions

View File

@@ -90,8 +90,6 @@ 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

@@ -1,10 +0,0 @@
<?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

@@ -1,10 +0,0 @@
<?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,7 +8,6 @@ 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
@@ -30,7 +29,6 @@ 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
@@ -103,7 +101,6 @@ class MainActivity : AppCompatActivity() {
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines, LocalShowHourLines provides settings.showHourLines,
@@ -118,10 +115,6 @@ class MainActivity : AppCompatActivity() {
onImportConsumed = { requestedImportUri = null }, 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(
report = report, report = report,

View File

@@ -118,14 +118,8 @@ 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). * clone starts from the parent's values). An exception is a single event:
* * 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,
@@ -137,7 +131,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.DURATION, times.toRfc2445Duration(form.isAllDay)) put(CalendarContract.Events.DTEND, times.dtEndMillis)
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,36 +276,35 @@ class SettingsPrefs @Inject constructor(
} }
/** /**
* The default reminder lead times (minutes before start) prefilled on new * The default reminder lead time (minutes before start) prefilled on new
* **timed** events. The empty list = no default reminder — the prior * **timed** events. `null` = no default reminder — the prior behaviour, kept
* behaviour, kept as the factory default so existing users aren't surprised by * as the factory default so existing users aren't surprised by reminders they
* reminders they never asked for. Stored as a comma-joined list of minutes, or * never asked for. Stored as a string so "none" is distinct from a numeric
* "none"/empty for no reminder (also the unset state). A legacy single value * value (and from an unset key, which is also "none"). Per-calendar overrides
* ("30") parses transparently to a one-element list. Per-calendar overrides in * in [perCalendarReminderOverride] take precedence; all-day events instead use
* [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/ */
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs -> val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderList() prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
} }
suspend fun setDefaultReminderMinutes(minutes: List<Int>) { suspend fun setDefaultReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() } store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
} }
/** /**
* The default reminder lead times prefilled on new **all-day** events, in * The default reminder lead time 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. Empty list = no default. Per-calendar overrides do **not** * the timed one. `null` = no default. Per-calendar overrides do **not** apply
* apply to all-day events — they always use this global value. * to all-day events — they always use this global value.
*/ */
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs -> val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList() prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
} }
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) { suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() } store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
} }
/** /**
@@ -342,13 +341,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: an **empty** list means "no reminder", a * timed default for its new events: a `null` value means "no reminder", an int
* non-empty list means those lead times. A calendar **absent** from the map * means that lead time. A calendar **absent** from the map inherits the global
* inherits the global default. Serialised as `id=value;id=value`, where a value * default. Serialised as `id=value;id=value`, with `none` for an explicit
* is a comma-joined minute list or `none` for an explicit no-reminder override. * no-reminder override. (All-day events ignore this and use
* (All-day events ignore this and use [defaultAllDayReminderMinutes].) * [defaultAllDayReminderMinutes].)
*/ */
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs -> val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
} }
@@ -365,7 +364,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, List<Int>>> = store.data.map { prefs -> val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
} }
@@ -496,46 +495,43 @@ 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
/** Specific lead times in minutes before the event start (non-empty). */ /** A specific lead time in minutes before the event start. */
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride data class Minutes(val minutes: Int) : CalendarReminderOverride
} }
/** /**
* The lead times to prefill on a new event: the matching per-calendar override * The lead time 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]. The empty list = no reminder. * events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
* Pure so it can be unit-tested. * it can be unit-tested.
*/ */
fun resolveDefaultReminder( fun resolveDefaultReminder(
timedGlobal: List<Int>, timedGlobal: Int?,
allDayGlobal: List<Int>, allDayGlobal: Int?,
timedOverrides: Map<Long, List<Int>>, timedOverrides: Map<Long, Int?>,
allDayOverrides: Map<Long, List<Int>>, allDayOverrides: Map<Long, Int?>,
calendarId: Long?, calendarId: Long?,
isAllDay: Boolean, isAllDay: Boolean,
): List<Int> { ): 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.getValue(calendarId) overrides[calendarId]
} else { } else {
global global
} }
} }
/** /** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the private fun MutableMap<Long, Int?>.applyOverride(
* 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, emptyList()) CalendarReminderOverride.None -> put(calendarId, null)
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders()) is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
} }
} }
@@ -561,36 +557,24 @@ 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 = ","
/** Distinct, ascending lead times — the canonical form stored and resolved. */ private fun String?.toReminderMinutes(): Int? = when (this) {
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted() null, "", NONE -> null
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()
} }
/** Serialise lead times for storage: "none" when empty, else comma-joined. */ private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
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
id to parts[1].toReminderList() val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap() }.toMap()
} }
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String = private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" } map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
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

@@ -1,50 +0,0 @@
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,7 +13,6 @@ 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
@@ -27,7 +26,6 @@ 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
@@ -113,15 +111,10 @@ fun <T> OptionPicker(
} }
/** /**
* Reminder-default picker, full-screen and **multi-select**: each [presets] * Reminder-default picker, full-screen: the grouped list (with an optional "Use
* lead time (plus any chosen custom value) is a checkbox row that toggles * default reminder" row and a "None" row), the [presets] as lead-time rows, and
* independently, so a default can carry several reminders ("1 week before" *and* * a "Custom" row that expands an inline number field plus a segmented unit
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use * selector. Returns the choice as a [CalendarReminderOverride].
* 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(
@@ -132,74 +125,57 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit, onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
// Optimistic local state: the chosen override is authoritative while the val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
// picker is open, so quick successive toggles compose on each other instead val customSelected = selectedMinutes != null && selectedMinutes !in presets
// of racing the round-trip through the settings flow (which would drop a val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
// 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("") } var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } var unit by rememberSaveable { mutableStateOf(seed.second) }
fun apply(override: CalendarReminderOverride) { val options = buildList {
current = override if (allowInherit) add(CalendarReminderOverride.Inherit)
onSelect(override) add(CalendarReminderOverride.None)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
} }
// Emit the override for a new set of minutes: empty collapses to None, which val rowCount = options.size + 1 // + the custom row
// (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) {
// Exclusive "use default" choice (per-calendar only): its own group above options.forEachIndexed { index, option ->
// the multi-select list, since inheriting is mutually exclusive with val isSelected = option == selected
// picking specific times.
if (allowInherit) {
GroupedRow( GroupedRow(
title = stringResource(R.string.reminder_use_default), title = reminderOverrideLabel(option),
position = Position.Alone, position = positionOf(index, rowCount),
selected = inherits, selected = isSelected,
trailing = if (inherits) { trailing = if (isSelected) {
{ SelectedCheck() } { SelectedCheck() }
} else { } else {
null null
}, },
onClick = { apply(CalendarReminderOverride.Inherit) }, onClick = {
) onSelect(option)
Spacer(Modifier.height(24.dp)) onDismiss()
} },
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 = stringResource(R.string.event_edit_reminder_custom), title = if (customSelected) {
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount), stringResource(
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(
@@ -213,9 +189,8 @@ fun ReminderDefaultPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
onConfirm = { minutes -> onConfirm = { minutes ->
emit(selectedMinutes + minutes) onSelect(CalendarReminderOverride.Minutes(minutes))
amountText = "" onDismiss()
customExpanded = false
}, },
) )
} }
@@ -453,3 +428,18 @@ 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: List<Int>, val timed: Int?,
val allDay: List<Int>, val allDay: Int?,
val timedOverrides: Map<Long, List<Int>>, val timedOverrides: Map<Long, Int?>,
val allDayOverrides: Map<Long, List<Int>>, val allDayOverrides: Map<Long, 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 reminders = resolveDefaultReminder( val default = resolveDefaultReminder(
timedGlobal = defaults.timed, timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay, allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides, timedOverrides = defaults.timedOverrides,
@@ -268,6 +268,7 @@ 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.toMinutesList()) }, onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
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.toMinutesList()) }, onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
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 (empty = none) as a picker choice for selection highlighting. */ /** A global default (null = none) as a picker choice for selection highlighting. */
private fun List<Int>.toReminderChoice(): CalendarReminderOverride = private fun Int?.toReminderChoice(): CalendarReminderOverride =
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) if (this == null) 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.toMinutesList(): List<Int> = private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList() (this as? CalendarReminderOverride.Minutes)?.minutes
/** /**
* Whether Calendula is exempt from battery optimisation, re-read on every * Whether Calendula is exempt from battery optimisation, re-read on every
@@ -1109,33 +1109,27 @@ 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, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when { private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit !containsKey(calendarId) -> CalendarReminderOverride.Inherit
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None this[calendarId] == null -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)) else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
} }
/** Label for a global-default choice: empty → "None", else the lead times joined. */ /** Label for a global-default choice: null → "None", else the lead time. */
@Composable @Composable
private fun reminderChoiceLabel(minutes: List<Int>): String { private fun reminderChoiceLabel(minutes: Int?): String =
if (minutes.isEmpty()) return stringResource(R.string.reminder_none) if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
// 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: List<Int>, globalDefault: 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 -> reminderChoiceLabel(choice.minutes) is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(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 times (minutes) prefilled on new timed events; * The default reminder lead time (minutes) prefilled on new timed events;
* empty = no default reminder. Per-calendar overrides take precedence. * null = no default reminder. Per-calendar overrides take precedence.
*/ */
val defaultReminderMinutes: List<Int> = emptyList(), val defaultReminderMinutes: Int? = null,
/** The default reminder lead times prefilled on new all-day events; empty = none. */ /** The default reminder lead time prefilled on new all-day events; null = none. */
val defaultAllDayReminderMinutes: List<Int> = emptyList(), val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */ /** 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 (empty value = no * calendar present in the map overrides the global default (null value = no
* reminder); absent = inherit the global default. * reminder); absent = inherit the global default.
*/ */
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(), val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */ /** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(), val perCalendarAllDayReminderOverride: Map<Long, 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: List<Int>, val defaultReminder: Int?,
val allDayReminder: List<Int>, val allDayReminder: 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, List<Int>>, val timed: Map<Long, Int?>,
val allDay: Map<Long, List<Int>>, val allDay: Map<Long, 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: List<Int>) { fun setDefaultReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
} }
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) { fun setDefaultAllDayReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
} }

View File

@@ -316,6 +316,7 @@
<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,6 +324,7 @@
<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,12 +199,11 @@ 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)
// The occurrence's length travels as DURATION, never DTEND — the provider assertThat(values[CalendarContract.Events.DTEND])
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and .isEqualTo(1_781_164_800_000L + 5_400_000L)
// derives the end from DTSTART + DURATION, clearing the inherited RRULE. // A single occurrence never carries its own rule.
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,38 +183,22 @@ class SettingsPrefsTest {
} }
@Test @Test
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest { fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isEmpty() assertThat(prefs.defaultReminderMinutes.first()).isNull()
} }
@Test @Test
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest { fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDefaultReminderMinutes(listOf(30)) prefs.setDefaultReminderMinutes(30)
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30) assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
// Several lead times persist, normalised to distinct ascending order. prefs.setDefaultReminderMinutes(null)
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30)) assertThat(prefs.defaultReminderMinutes.first()).isNull()
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
prefs.setDefaultReminderMinutes(emptyList())
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest { fun `garbage stored default reminder reads as none`(@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 ->
@@ -222,7 +206,7 @@ class SettingsPrefsTest {
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m m
} }
assertThat(prefs.defaultReminderMinutes.first()).isEmpty() assertThat(prefs.defaultReminderMinutes.first()).isNull()
} }
@Test @Test
@@ -230,16 +214,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(listOf(15, 10_080))) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map -> prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>()) assertThat(map).containsExactly(7L, 15, 9L, null)
} }
// Inherit drops the override entirely (absent != an empty-list value). // Inherit drops the override entirely (absent != null 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, listOf(15, 10_080)) assertThat(map).containsExactly(7L, 15)
assertThat(map.containsKey(9L)).isFalse() assertThat(map.containsKey(9L)).isFalse()
} }
} }
@@ -247,48 +231,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()).isEmpty() assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
prefs.setDefaultAllDayReminderMinutes(listOf(1_440)) prefs.setDefaultAllDayReminderMinutes(1_440)
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440) assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
prefs.setDefaultAllDayReminderMinutes(emptyList()) prefs.setDefaultAllDayReminderMinutes(null)
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty() assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
} }
@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(listOf(15))) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440))) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15)) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440)) assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 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, listOf(15)) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 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 listOf(15, 10_080), 9L to emptyList<Int>()) val timed = mapOf(7L to 15, 9L to null)
val allDay = mapOf(7L to listOf(2_880)) val allDay = mapOf(7L to 2_880)
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder( fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = listOf(30), timedGlobal = 30,
allDayGlobal = listOf(1_440), allDayGlobal = 1_440,
timedOverrides = timed, timedOverrides = timed,
allDayOverrides = allDay, allDayOverrides = allDay,
calendarId = calendarId, calendarId = calendarId,
isAllDay = isAllDay, isAllDay = isAllDay,
) )
// Timed: multi-value override, explicit none, inherit global, no calendar. // Timed: minutes override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder() assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
assertThat(resolve(9L, isAllDay = false)).isEmpty() assertThat(resolve(9L, isAllDay = false)).isNull()
assertThat(resolve(5L, isAllDay = false)).containsExactly(30) assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
assertThat(resolve(null, isAllDay = false)).containsExactly(30) assertThat(resolve(null, isAllDay = false)).isEqualTo(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)).containsExactly(2_880) assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440) assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440) assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
} }
@Test @Test

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true