diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ab8105..6d973ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
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
@@ -729,3 +751,6 @@ 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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b20a171..72977e7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -90,6 +90,8 @@ 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
diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml
new file mode 100644
index 0000000..87de5a9
--- /dev/null
+++ b/app/src/debug/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+
+ #FFB23B00
+
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 0000000..5dad449
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+ Calendula Debug
+
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
index a2de42c..ede492a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
@@ -8,6 +8,7 @@ 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
@@ -29,6 +30,7 @@ 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
@@ -101,19 +103,24 @@ class MainActivity : AppCompatActivity() {
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
- 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 },
- )
+ 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()
}
pendingCrashReport?.let { report ->
CrashReportDialog(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
index 0f50930..2c3928c 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
@@ -118,8 +118,14 @@ 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). An exception is a single event:
- * DTEND, never RRULE/DURATION.
+ * 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).
*/
internal fun buildOccurrenceExceptionValues(
form: EventForm,
@@ -131,7 +137,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.DTEND, times.dtEndMillis)
+ put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
index 1a1f1d5..4fed5ad 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
@@ -276,35 +276,36 @@ class SettingsPrefs @Inject constructor(
}
/**
- * 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
+ * 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
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/
- val defaultReminderMinutes: Flow = store.data.map { prefs ->
- prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
+ val defaultReminderMinutes: Flow> = store.data.map { prefs ->
+ prefs[DEFAULT_REMINDER_KEY].toReminderList()
}
- suspend fun setDefaultReminderMinutes(minutes: Int?) {
- store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
+ suspend fun setDefaultReminderMinutes(minutes: List) {
+ store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
}
/**
- * The default reminder lead time prefilled on new **all-day** events, in
+ * The default reminder lead times prefilled on new **all-day** events, in
* minutes before the start of the day. All-day events want day-scale lead
* times ("1 day before"), so they have their own default rather than reusing
- * the timed one. `null` = no default. Per-calendar overrides do **not** apply
- * to all-day events — they always use this global value.
+ * the timed one. Empty list = no default. Per-calendar overrides do **not**
+ * apply to all-day events — they always use this global value.
*/
- val defaultAllDayReminderMinutes: Flow = store.data.map { prefs ->
- prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
+ val defaultAllDayReminderMinutes: Flow> = store.data.map { prefs ->
+ prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
}
- suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
- store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
+ suspend fun setDefaultAllDayReminderMinutes(minutes: List) {
+ store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
}
/**
@@ -341,13 +342,13 @@ class SettingsPrefs @Inject constructor(
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
- * timed default for its new events: a `null` value means "no reminder", an int
- * 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].)
+ * 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].)
*/
- val perCalendarReminderOverride: Flow