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
22 changed files with 202 additions and 436 deletions

View File

@@ -5,28 +5,6 @@ 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/),
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
### Added
@@ -751,6 +729,3 @@ automatically, with zero telemetry and no internet permission.
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17

View File

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

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.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
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.WidgetNavRequest
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.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
@@ -103,24 +101,19 @@ class MainActivity : AppCompatActivity() {
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = 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()
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
}
pendingCrashReport?.let { report ->
CrashReportDialog(

View File

@@ -118,14 +118,8 @@ internal fun buildEventUpdateValues(
* 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
* form-backed column is written (empty optionals as explicit NULLs, since the
* clone starts from the parent's values).
*
* 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).
* clone starts from the parent's values). An exception is a single event:
* DTEND, never RRULE/DURATION.
*/
internal fun buildOccurrenceExceptionValues(
form: EventForm,
@@ -137,7 +131,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
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.AVAILABILITY, form.availability.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
* **timed** events. The empty list = no default reminder — the prior
* behaviour, kept as the factory default so existing users aren't surprised by
* reminders they never asked for. Stored as a comma-joined list of minutes, or
* "none"/empty for no reminder (also the unset state). A legacy single value
* ("30") parses transparently to a one-element list. Per-calendar overrides in
* [perCalendarReminderOverride] take precedence; all-day events instead use
* The default reminder lead time (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept
* as the factory default so existing users aren't surprised by reminders they
* never asked for. Stored as a string so "none" is distinct from a numeric
* value (and from an unset key, which is also "none"). Per-calendar overrides
* in [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderList()
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
}
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
suspend fun setDefaultReminderMinutes(minutes: Int?) {
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
* 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**
* apply to all-day events — they always use this global value.
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
* to all-day events — they always use this global value.
*/
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
}
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
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,
* 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
* non-empty list means those lead times. A calendar **absent** from the map
* inherits the global default. Serialised as `id=value;id=value`, where a value
* is a comma-joined minute list or `none` for an explicit no-reminder override.
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
* timed default for its new events: a `null` value means "no reminder", an int
* means that lead time. A calendar **absent** from the map inherits the global
* default. Serialised as `id=value;id=value`, with `none` for an explicit
* no-reminder override. (All-day events ignore this and use
* [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])
}
@@ -365,7 +364,7 @@ class SettingsPrefs @Inject constructor(
* events, with the same semantics as [perCalendarReminderOverride] (absent =
* 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])
}
@@ -496,46 +495,43 @@ sealed interface CalendarReminderOverride {
data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride
/** Specific lead times in minutes before the event start (non-empty). */
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */
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
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
* Pure so it can be unit-tested.
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
* it can be unit-tested.
*/
fun resolveDefaultReminder(
timedGlobal: List<Int>,
allDayGlobal: List<Int>,
timedOverrides: Map<Long, List<Int>>,
allDayOverrides: Map<Long, List<Int>>,
timedGlobal: Int?,
allDayGlobal: Int?,
timedOverrides: Map<Long, Int?>,
allDayOverrides: Map<Long, Int?>,
calendarId: Long?,
isAllDay: Boolean,
): List<Int> {
): Int? {
val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides.getValue(calendarId)
overrides[calendarId]
} else {
global
}
}
/**
* 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(
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
private fun MutableMap<Long, Int?>.applyOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, emptyList())
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
CalendarReminderOverride.None -> put(calendarId, null)
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 ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "="
private const val LIST_SEP = ","
/** Distinct, ascending lead times — the canonical form stored and resolved. */
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
/**
* 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 String?.toReminderMinutes(): Int? = when (this) {
null, "", NONE -> null
else -> toIntOrNull()
}
/** 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>> {
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: 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()
}
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
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 =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -8,7 +8,6 @@ import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,17 +16,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
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
* visible notifications (the Etar model — the provider broadcasts
@@ -44,7 +32,6 @@ class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
@@ -59,11 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
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) }
due.forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
}
} finally {

View File

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

View File

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

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.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -27,7 +26,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -113,15 +111,10 @@ fun <T> OptionPicker(
}
/**
* Reminder-default picker, full-screen and **multi-select**: each [presets]
* lead time (plus any chosen custom value) is a checkbox row that toggles
* independently, so a default can carry several reminders ("1 week before" *and*
* "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.
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
* a "Custom" row that expands an inline number field plus a segmented unit
* selector. Returns the choice as a [CalendarReminderOverride].
*/
@Composable
fun ReminderDefaultPicker(
@@ -132,74 +125,57 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
// Optimistic local state: the chosen override is authoritative while the
// picker is open, so quick successive toggles compose on each other instead
// 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()
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(seed.second) }
fun apply(override: CalendarReminderOverride) {
current = override
onSelect(override)
val options = buildList {
if (allowInherit) add(CalendarReminderOverride.Inherit)
add(CalendarReminderOverride.None)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
}
// 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)
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
// Exclusive "use default" choice (per-calendar only): its own group above
// the multi-select list, since inheriting is mutually exclusive with
// picking specific times.
if (allowInherit) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = stringResource(R.string.reminder_use_default),
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
title = reminderOverrideLabel(option),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(CalendarReminderOverride.Inherit) },
)
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) },
onClick = {
onSelect(option)
onDismiss()
},
)
}
// When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
title = if (customSelected) {
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 },
)
AnimatedVisibility(
@@ -213,9 +189,8 @@ fun ReminderDefaultPicker(
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
emit(selectedMinutes + minutes)
amountText = ""
customExpanded = false
onSelect(CalendarReminderOverride.Minutes(minutes))
onDismiss()
},
)
}
@@ -453,3 +428,18 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
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(
val timed: List<Int>,
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
val timed: Int?,
val allDay: Int?,
val timedOverrides: Map<Long, Int?>,
val allDayOverrides: Map<Long, Int?>,
)
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.
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = resolveDefaultReminder(
val default = resolveDefaultReminder(
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
@@ -268,6 +268,7 @@ class EventEditViewModel @Inject constructor(
calendarId = targetId,
isAllDay = form.isAllDay,
)
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.

View File

@@ -969,7 +969,7 @@ private fun NotificationsScreen(
presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
onDismiss = { showDefaultReminder = false },
)
}
@@ -979,7 +979,7 @@ private fun NotificationsScreen(
presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
onDismiss = { showAllDayReminder = false },
)
}
@@ -1028,13 +1028,13 @@ private fun NotificationsScreen(
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (empty = none) as a picker choice for selection highlighting. */
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A global default (null = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
(this as? CalendarReminderOverride.Minutes)?.minutes
/**
* 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). */
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
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
this[calendarId] == null -> CalendarReminderOverride.None
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
private fun reminderChoiceLabel(minutes: List<Int>): String {
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(", ")
}
private fun reminderChoiceLabel(minutes: Int?): String =
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
/** Row summary for a calendar: its override, or the inherited global default. */
@Composable
private fun calendarOverrideSummary(
choice: CalendarReminderOverride,
globalDefault: List<Int>,
globalDefault: Int?,
): String = when (choice) {
CalendarReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
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). */
val remindersEnabled: Boolean = true,
/**
* The default reminder lead times (minutes) prefilled on new timed events;
* empty = no default reminder. Per-calendar overrides take precedence.
* The default reminder lead time (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence.
*/
val defaultReminderMinutes: List<Int> = emptyList(),
/** The default reminder lead times prefilled on new all-day events; empty = none. */
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
val defaultReminderMinutes: Int? = null,
/** The default reminder lead time prefilled on new all-day events; null = none. */
val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (empty value = no
* calendar present in the map overrides the global default (null value = no
* 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. */
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/**

View File

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

View File

@@ -316,6 +316,7 @@
<string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</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="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>

View File

@@ -324,6 +324,7 @@
<string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</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="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>

View File

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

View File

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

View File

@@ -1,55 +0,0 @@
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)
}
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
networkTimeout=10000
validateDistributionUrl=true