diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8da9d1..4762245 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,44 @@ 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]
+
+## [2.14.1] — 2026-07-13
+
+### Fixed
+- Deleting one occurrence of a repeating event no longer breaks the series.
+ Choosing "This event" when deleting an occurrence of a recurring event could
+ wipe out every *other* occurrence while leaving the one you deleted behind as
+ a stale, still-tappable ghost — and deleting it again brought the series back.
+ A single-occurrence delete now removes exactly that occurrence and leaves the
+ rest of the series untouched, and the deleted occurrence disappears from the
+ grid straight away. This holds on every kind of calendar, including the
+ on-device ones Calendula keeps for contact birthdays and anniversaries, where
+ the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
+- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
+ Calendula to answer the "new event" hand-off from other apps and widgets; now
+ it also answers the "open this event" one, so tapping an existing event in a
+ widget such as Todo Agenda offers Calendula and lands on that event's details.
+ Thanks to @bushrang3r for the report ([#48]).
+- Events created from other apps get your default reminder. An event handed over
+ by another app or widget — Google Maps' "add to calendar", the Todo Agenda
+ widget's "+" — opened with no reminder at all, ignoring the default set in
+ Settings. It now starts with your default reminder, the same as an event you
+ create in Calendula. An event opened from an `.ics` file is treated differently,
+ because the file has its own say: Calendula keeps whatever reminders it carries
+ (including none at all) and asks you once whether to apply your default instead
+ — it never quietly overrides the file. If you have no default set, it doesn't
+ ask ([#49]).
+- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
+ app (such as DAVx5), the event colour picker showed every colour the account
+ publishes — nearly 150 swatches in alphabetical order, many of them
+ duplicates or near-identical shades. The picker now shows only visually
+ distinct colours, arranged as a rainbow; near-duplicate shades and the
+ washed-out neutrals are folded away so no two swatches look alike. Picked
+ colours still sync exactly as before, and calendars with hand-picked
+ palettes (like Google's) are unaffected. Thanks to @ptab for the report
+ ([#22]).
+
## [2.14.0] — 2026-07-06
### Added
@@ -865,6 +903,7 @@ automatically, with zero telemetry and no internet permission.
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
+[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
@@ -874,3 +913,6 @@ automatically, with zero telemetry and no internet permission.
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
+[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
+[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
+[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6985952..4f82ef9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
- versionCode = 21400
- versionName = "2.14.0"
+ versionCode = 21401
+ versionName = "2.14.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1139b8b..47866af 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -123,6 +123,22 @@
+
+
+
+
+
+
+
+
`, the way AOSP fires it (e.g.
+ * tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
+ * same occurrence-key channel as reminder taps. The launcher passes the
+ * occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
+ * it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
+ * [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
+ * rather than rendering at the epoch.
+ */
+ private fun Intent.viewEventKeyOrNull(): LongArray? {
+ if (action != Intent.ACTION_VIEW) return null
+ val uri = data ?: return null
+ if (uri.host != CALENDAR_PROVIDER_HOST) return null
+ val segments = uri.pathSegments
+ if (segments.firstOrNull() != "events") return null
+ val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
+ return longArrayOf(
+ eventId,
+ longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
+ longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
+ )
+ }
+
companion object {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/.
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
index eeb7821..110518e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
+import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
@@ -63,10 +64,12 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
- * (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
- * account exposes no palette (most local calendars, some CalDAV) — the
- * signal that a custom colour can only be written as a raw `EVENT_COLOR`,
- * which a synced calendar may drop on its next sync.
+ * (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
+ * thinned to visually distinct swatches when oversized (CalDAV adapters
+ * publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
+ * Empty when the account exposes no palette (most local calendars, some
+ * CalDAV) — the signal that a custom colour can only be written as a raw
+ * `EVENT_COLOR`, which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List
@@ -492,7 +495,13 @@ class AndroidCalendarDataSource @Inject constructor(
return resolver.query(
uri,
InstanceProjection.COLUMNS,
- null, null,
+ // Hide cancelled occurrences: "delete only this event" writes a
+ // cancelled exception for the one instance (#47). A NULL status is a
+ // normal, un-cancelled event, so it must survive the filter — a bare
+ // `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
+ "${CalendarContract.Instances.STATUS} IS NULL OR " +
+ "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
+ null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
@@ -611,7 +620,7 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
- ?.sortedBy { it.key }
+ ?.curatedForPicker()
?: emptyList()
}
@@ -970,6 +979,8 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
+ CalendarContract.Events._SYNC_ID,
+ CalendarContract.Events.EXDATE,
),
null, null, null,
)?.use { c ->
@@ -980,6 +991,8 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = c.getString(2),
duration = c.getString(3),
allDay = c.getInt(4),
+ syncId = c.getString(5),
+ exdate = c.getString(6),
)
} else {
null
@@ -992,6 +1005,9 @@ class AndroidCalendarDataSource @Inject constructor(
val timezone: String?,
val duration: String?,
val allDay: Int,
+ /** Null on a local calendar (and before a synced event's first push). */
+ val syncId: String? = null,
+ val exdate: String? = null,
) {
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
@@ -1182,16 +1198,49 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
- // A cancelled exception row hides exactly this occurrence; the sync
- // adapter turns it into an EXDATE/cancelled VEVENT upstream.
- val values = ContentValues().apply {
- put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
- put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
+ val row = querySeriesRow(eventId)
+ if (row.syncId == null) {
+ // No _sync_id — a local calendar, or a synced event not pushed yet.
+ // A cancelled exception can only attach to its parent through
+ // ORIGINAL_SYNC_ID, so with none the link never forms and the
+ // provider's expansion of the *parent* collapses, taking every other
+ // occurrence with it (#47 on a local calendar). EXDATE needs no link.
+ // Calendula's own contact special-date calendars are local and hold
+ // yearly series, so this path is reached in normal use.
+ val values = buildOccurrenceExdateValues(
+ existingExdate = row.exdate,
+ occurrenceMillis = beginMillis,
+ dtStartMillis = row.dtStartMillis,
+ rrule = row.rrule,
+ duration = row.duration,
+ timezone = row.timezone,
+ allDay = row.allDay,
+ )
+ val updated = resolver.update(
+ ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
+ values.toContentValues(), null, null,
+ )
+ if (updated == 0) {
+ throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
+ }
+ return
}
+ // A cancelled exception row hides exactly this occurrence; the sync
+ // adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
+ // the full time set (DTSTART + DURATION + zone) so the provider derives a
+ // single instance rather than cloning the master's RRULE — the same trap
+ // the edit path documents (Codeberg #16).
+ val values = buildOccurrenceCancelValues(
+ originalInstanceMillis = beginMillis,
+ dtStartMillis = beginMillis,
+ duration = row.duration,
+ timezone = row.timezone,
+ allDay = row.allDay,
+ )
val uri = ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
)
- resolver.insert(uri, values)
+ resolver.insert(uri, values.toContentValues())
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt
index bc4be53..3711c81 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
+import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's
- // DTEND is null. Keep the event (end == begin); callers that opened a
- // specific occurrence supply the real per-occurrence times from
- // CalendarContract.Instances. A present-but-backwards DTEND is malformed,
- // but dropping the row would make the event un-openable — the same trap as
- // the pre-1970 DTSTART bug above (issue #34): it would surface as the
- // generic error screen with no way to open the event and fix it. Clamp to a
- // zero-length event instead (matching SearchMapper's coerceAtLeast).
+ // DTEND is null — derive the length from DURATION (as SearchMapper and
+ // IcsExportMapper do). Callers that opened a specific occurrence overwrite
+ // both times with the per-occurrence values from CalendarContract.Instances;
+ // a caller that names no occurrence (a bare content://.../events/ VIEW
+ // intent, issue #48) keeps this row's own times, so the length has to be
+ // right here or the series renders zero-length. A present-but-backwards
+ // DTEND is malformed, but dropping the row would make the event un-openable
+ // — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
+ // surface as the generic error screen with no way to open the event and fix
+ // it. Clamp to a zero-length event instead (matching SearchMapper).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
- begin
+ begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
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 dc830aa..0768f55 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
@@ -182,6 +182,100 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color))
}
+/**
+ * Column values for a *cancelled*-occurrence exception row ("delete only this
+ * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/` makes the
+ * provider clone the series row and cancel exactly this one instance.
+ *
+ * As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
+ * DTSTART + DURATION so the provider derives a single instance and clears the
+ * inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
+ * the *whole series* is cancelled and every other occurrence disappears
+ * (Codeberg #47). The occurrence's length/zone come straight from the series row
+ * — cancelling never changes them.
+ */
+internal fun buildOccurrenceCancelValues(
+ originalInstanceMillis: Long,
+ dtStartMillis: Long,
+ duration: String?,
+ timezone: String?,
+ allDay: Int,
+): Map = buildMap {
+ put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
+ put(CalendarContract.Events.DTSTART, dtStartMillis)
+ put(CalendarContract.Events.DURATION, duration)
+ put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
+ put(CalendarContract.Events.ALL_DAY, allDay)
+ put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
+}
+
+/**
+ * The master-row columns that drop the occurrence at [occurrenceMillis] from a
+ * series by adding it to `EXDATE` — the path for events that have **no
+ * `_sync_id`** (a local calendar, or a synced event not yet pushed).
+ *
+ * A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
+ * its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
+ * forms, and the provider's expansion of the *parent* collapses — every other
+ * occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
+ * needs no link, and is the canonical iCalendar way to drop an occurrence, so a
+ * sync adapter carries it upstream unchanged if the calendar later syncs.
+ *
+ * The whole time/recurrence set is rewritten alongside it on purpose. The
+ * provider does **not** treat an EXDATE-only update as a recurrence change: it
+ * leaves the expanded `Instances` rows untouched, so the occurrence stays visible
+ * (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
+ * it forces the re-expansion — but DTSTART *alone* makes the provider recompute
+ * `lastDate` as if the event were a single instance, collapsing the series to its
+ * first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
+ * re-expands it correctly. All observed on a Pixel; see the #47 notes.
+ *
+ * EXDATE is a comma-separated list, so an existing one is appended to (a repeat
+ * of the same occurrence is folded away). All-day series take the `VALUE=DATE`
+ * form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
+ */
+internal fun buildOccurrenceExdateValues(
+ existingExdate: String?,
+ occurrenceMillis: Long,
+ dtStartMillis: Long,
+ rrule: String?,
+ duration: String?,
+ timezone: String?,
+ allDay: Int,
+): Map {
+ val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
+ val existing = existingExdate?.split(',')
+ ?.map { it.trim() }
+ ?.filter { it.isNotEmpty() }
+ .orEmpty()
+ val merged = (existing + stamp).distinct().joinToString(",")
+ return mapOf(
+ CalendarContract.Events.EXDATE to merged,
+ CalendarContract.Events.DTSTART to dtStartMillis,
+ CalendarContract.Events.RRULE to rrule,
+ CalendarContract.Events.DURATION to duration,
+ CalendarContract.Events.EVENT_TIMEZONE to timezone,
+ CalendarContract.Events.ALL_DAY to allDay,
+ )
+}
+
+/**
+ * One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
+ * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
+ * reads off the UTC calendar day.
+ */
+private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
+ val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
+ return if (isAllDay) {
+ "%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
+ } else {
+ "%04d%02d%02dT%02d%02d%02dZ".format(
+ utc.year, utc.monthValue, utc.dayOfMonth,
+ utc.hour, utc.minute, utc.second,
+ )
+ }
+}
+
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
index 2066ac7..2771195 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
@@ -84,6 +84,9 @@ internal object EventDetailProjection {
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.EVENT_COLOR_KEY,
+ // Recurring rows carry DURATION instead of DTEND; the detail screen
+ // needs it to render a series opened without a named occurrence.
+ CalendarContract.Events.DURATION,
)
const val IDX_EVENT_ID = 0
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
+ const val IDX_DURATION = 18
}
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt
new file mode 100644
index 0000000..8130f57
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt
@@ -0,0 +1,185 @@
+package de.jeanlucmakiola.calendula.domain
+
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cbrt
+import kotlin.math.hypot
+import kotlin.math.pow
+import kotlin.math.roundToInt
+import kotlin.math.sqrt
+
+/**
+ * Curates an account's published event palette for the colour picker.
+ *
+ * Sync adapters differ wildly in what they publish: Google exposes a
+ * hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
+ * named colours — including exact-value aliases (aqua/cyan, the gray/grey
+ * spelling pairs) and dozens of visually indistinguishable whites and grays
+ * (#22).
+ *
+ * Crucially, curation runs against the colour the picker actually *paints*, not
+ * the raw provider value. The picker softens every swatch through [pastelArgb]:
+ * it pins lightness to a constant and caps saturation, so the raw palette's
+ * lightness axis is invisible on screen. Two raw colours that look different —
+ * a navy and a mid blue — paint as one swatch, and every neutral (black, the
+ * grays, white) paints as the same pale tint. Judging distinctness in raw
+ * space, as before, left near-identical painted swatches and stranded the
+ * neutrals as a run of look-alike "pinks" at the end of the grid.
+ *
+ * Three steps, all in painted space:
+ * 1. Collapse swatches that paint identically to one (alphabetically-first key
+ * wins, deterministically) — this folds aliases, dark/light shades of a
+ * hue, and all the neutrals together.
+ * 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
+ * neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
+ * then thinned to visually distinct colours: most vivid first, a colour is
+ * kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
+ * colour already kept. Small palettes are already curated by their adapter
+ * and pass through whole.
+ * 3. The survivors are ordered like a rainbow — continuously by painted hue —
+ * with the wheel cut at its single widest empty gap so the one unavoidable
+ * seam lands in dead space and no hue family is torn across both ends.
+ *
+ * Every surviving option keeps its provider [EventColorOption.key], so a pick
+ * still round-trips through sync.
+ */
+fun List.curatedForPicker(): List {
+ val painted = sortedBy { it.key }
+ .distinctBy { pastelArgb(it.argb) }
+ .map { it to Lab.of(pastelArgb(it.argb)) }
+ val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
+ painted
+ } else {
+ thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
+ }
+ return orderAroundWheel(kept).map { (option, _) -> option }
+}
+
+/**
+ * Orders swatches continuously around the (painted) hue wheel, then cuts the
+ * circle at its widest angular gap so the single seam lands in empty space
+ * instead of mid-family. Saturation breaks ties, vivid first.
+ */
+private fun orderAroundWheel(
+ swatches: List>,
+): List> {
+ if (swatches.size < 2) return swatches
+ val byHue = swatches.sortedWith(
+ compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
+ )
+ // Split the wheel after the largest empty arc between neighbouring hues;
+ // the default is the wrap gap (last hue back round to the first), i.e. the
+ // familiar 0→360 order, and we only rotate away from it for a wider void.
+ var cutAfter = byHue.lastIndex
+ var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
+ for (i in 0 until byHue.lastIndex) {
+ val gap = byHue[i + 1].second.hue - byHue[i].second.hue
+ if (gap > widestGap) {
+ widestGap = gap
+ cutAfter = i
+ }
+ }
+ return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
+}
+
+/** Greedy max-distance filter: vivid colours stake out clusters first. */
+private fun thin(
+ swatches: List>,
+): List> {
+ val byVividness = swatches
+ .sortedWith(compareByDescending> { it.second.chroma }.thenBy { it.first.key })
+ val kept = mutableListOf>()
+ for (candidate in byVividness) {
+ if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
+ }
+ return kept
+}
+
+/**
+ * The softening the colour picker paints over every swatch: keep the hue, scale
+ * and clamp saturation into a gentle band, and pin value to a constant so
+ * nothing screams and everything reads on the surface. Value is fixed here so
+ * curation is theme-independent — only hue and saturation distinguish painted
+ * swatches.
+ *
+ * This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
+ * shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
+ * rather than theme-picked. Curation must reason about the colour the picker
+ * paints, so the two shapings have to agree: if floret's saturation band or
+ * curve changes, update this in step.
+ */
+fun pastelArgb(rawArgb: Int): Int {
+ val r = ((rawArgb shr 16) and 0xFF) / 255f
+ val g = ((rawArgb shr 8) and 0xFF) / 255f
+ val b = (rawArgb and 0xFF) / 255f
+ val max = maxOf(r, g, b)
+ val min = minOf(r, g, b)
+ val delta = max - min
+ val hue = when {
+ delta == 0f -> 0f
+ max == r -> 60f * (((g - b) / delta) % 6f)
+ max == g -> 60f * (((b - r) / delta) + 2f)
+ else -> 60f * (((r - g) / delta) + 4f)
+ }.let { if (it < 0f) it + 360f else it }
+ val sat = (if (max == 0f) 0f else delta / max) * 0.6f
+ val s = sat.coerceIn(0.25f, 0.65f)
+ val v = PASTEL_VALUE
+ val c = v * s
+ val x = c * (1f - abs((hue / 60f) % 2f - 1f))
+ val m = v - c
+ val (rr, gg, bb) = when {
+ hue < 60f -> Triple(c, x, 0f)
+ hue < 120f -> Triple(x, c, 0f)
+ hue < 180f -> Triple(0f, c, x)
+ hue < 240f -> Triple(0f, x, c)
+ hue < 300f -> Triple(x, 0f, c)
+ else -> Triple(c, 0f, x)
+ }
+ fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
+ return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
+}
+
+/** Reference lightness for curation; the picker paints at this on dark surfaces. */
+private const val PASTEL_VALUE = 0.82f
+
+/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
+private const val CURATION_TRIGGER_SIZE = 36
+
+/** Minimum CIE76 ΔE between surviving painted swatches. */
+private const val MIN_DELTA_E = 13.0
+
+/**
+ * Painted-chroma floor for oversized palettes: below this a swatch is a washed-
+ * out tint — the neutrals and near-whites the saturation clamp muddies — so it
+ * is dropped rather than shown as pale filler.
+ */
+private const val PASTEL_CHROMA_FLOOR = 22.0
+
+/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
+private class Lab(val l: Double, val a: Double, val b: Double) {
+ val chroma: Double get() = hypot(a, b)
+
+ /** Hue angle in degrees, 0–360, around the Lab a-b plane. */
+ val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
+
+ fun deltaE(other: Lab): Double =
+ sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
+
+ companion object {
+ fun of(argb: Int): Lab {
+ fun linear(shift: Int): Double {
+ val c = ((argb shr shift) and 0xFF) / 255.0
+ return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
+ }
+ val r = linear(16)
+ val g = linear(8)
+ val b = linear(0)
+ val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
+ val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
+ fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
+ val fy = f(y)
+ return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index 436e87d..a70951b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
+import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
@@ -172,6 +173,9 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf(null) }
var importForm by remember { mutableStateOf(null) }
+ // Which channel filled [importForm]: an .ics file (prompt to apply the default
+ // reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
+ var importFormSource by remember { mutableStateOf(ImportSource.File) }
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
@@ -190,6 +194,7 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
+ importFormSource = ImportSource.Insert
importForm = requestedInsertForm
onInsertConsumed()
}
@@ -435,6 +440,7 @@ fun CalendarHost(
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null
+ importFormSource = ImportSource.File
importForm = form
},
)
@@ -443,6 +449,7 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = null,
initialForm = form,
+ initialFormSource = importFormSource,
onClose = { importForm = null },
onSaved = { importForm = null },
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt
index 8374c21..4a4aebd 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt
@@ -141,13 +141,20 @@ class EventDetailViewModel @Inject constructor(
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
- // occurrence's time so recurring events render correctly.
- val corrected = detail.copy(
- instance = detail.instance.copy(
- start = Instant.fromEpochMilliseconds(target.beginMillis),
- end = Instant.fromEpochMilliseconds(target.endMillis),
- ),
- )
+ // occurrence's time so recurring events render correctly. An external
+ // "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
+ // bare content://.../events/ VIEW intent, issue #48) keeps the row's
+ // own DTSTART/DTEND instead of overriding it to the epoch.
+ val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
+ detail
+ } else {
+ detail.copy(
+ instance = detail.instance.copy(
+ start = Instant.fromEpochMilliseconds(target.beginMillis),
+ end = Instant.fromEpochMilliseconds(target.endMillis),
+ ),
+ )
+ }
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
EventDetailUiState.Success(
@@ -168,6 +175,16 @@ class EventDetailViewModel @Inject constructor(
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
+
+ companion object {
+ /**
+ * Sentinel begin/end for an "open this event" that names no occurrence —
+ * a bare `content://com.android.calendar/events/` VIEW intent with no
+ * `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
+ * row's own DTSTART/DTEND instead of overriding it to the epoch.
+ */
+ const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
+ }
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
index 66944f1..b83f48e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
@@ -89,6 +89,7 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
@@ -182,12 +183,14 @@ fun EventEditScreen(
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialForm: EventForm? = null,
+ initialFormSource: ImportSource = ImportSource.File,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
when {
- // Single-event .ics open: the form arrives prefilled for review.
- initialForm != null -> viewModel.openImported(initialForm)
+ // A prefilled open: a single-event .ics for review, or an external
+ // ACTION_INSERT intent. The source drives how reminders are seeded.
+ initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
editKey != null -> viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
@@ -202,6 +205,7 @@ fun EventEditScreen(
}
val state by viewModel.state.collectAsStateWithLifecycle()
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
+ val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
// The form is intentionally forgotten on every close (cancel or save) so
// the next open starts clean; it survives rotation because openNew /
@@ -332,6 +336,56 @@ fun EventEditScreen(
},
)
}
+
+ // A .ics import respects the file's reminders, but offers to swap in the
+ // configured default rather than silently deciding for the user (#49).
+ importReminderPrompt?.let { prompt ->
+ ImportReminderPromptDialog(
+ currentReminderCount = prompt.currentReminderCount,
+ onApply = viewModel::applyImportedReminderDefault,
+ onKeep = viewModel::dismissImportedReminderPrompt,
+ )
+ }
+}
+
+/**
+ * Offer to apply the settings default reminder to an event opened from a `.ics`
+ * file. The file's own reminders are kept unless the user accepts. A plain
+ * two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
+ */
+@Composable
+private fun ImportReminderPromptDialog(
+ currentReminderCount: Int,
+ onApply: () -> Unit,
+ onKeep: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onKeep,
+ title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
+ text = {
+ Text(
+ if (currentReminderCount == 0) {
+ stringResource(R.string.import_reminder_prompt_body_none)
+ } else {
+ pluralStringResource(
+ R.plurals.import_reminder_prompt_body_existing,
+ currentReminderCount,
+ currentReminderCount,
+ )
+ },
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = onApply) {
+ Text(stringResource(R.string.import_reminder_prompt_apply))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onKeep) {
+ Text(stringResource(R.string.import_reminder_prompt_keep))
+ }
+ },
+ )
}
/**
@@ -1747,12 +1801,21 @@ private fun ColorPickerDialog(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
) {
if (palette.isNotEmpty()) {
+ // The event's current colour may not be in the curated palette
+ // (a thinned near-duplicate, or a raw colour set elsewhere) —
+ // append it so the selection ring has a home.
+ val swatches = palette.map { it.argb }.let {
+ if (selected != null && selected !in it) it + selected else it
+ }
ColorSwatchRow(
- colors = palette.map { it.argb },
+ colors = swatches,
selected = selected,
onSelect = { argb ->
- palette.firstOrNull { it.argb == argb }
- ?.let { onPickKey(it.key, it.argb) }
+ val option = palette.firstOrNull { it.argb == argb }
+ // The appended current colour has no provider key to
+ // write — it is already the event's colour, so just
+ // close.
+ if (option != null) onPickKey(option.key, option.argb) else onDismiss()
},
dark = dark,
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
index e2da425..0779385 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
@@ -50,6 +50,36 @@ import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import javax.inject.Inject
+/**
+ * Where a prefilled [EventEditViewModel.openImported] form came from — the two
+ * sources want different reminder handling (#49).
+ */
+enum class ImportSource {
+ /**
+ * An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
+ * It carries no reminders of its own, so the settings default is applied
+ * automatically, exactly like an in-app new event.
+ */
+ Insert,
+
+ /**
+ * A parsed single-event `.ics` file. Its own reminders are respected; the
+ * settings default is offered through [EventEditViewModel.importReminderPrompt]
+ * rather than silently applied or suppressed.
+ */
+ File,
+}
+
+/**
+ * A pending offer to swap an imported `.ics` event's reminders for the settings
+ * default (#49). [currentReminderCount] is what the file carried (0 or more);
+ * [defaultReminders] is what accepting would set.
+ */
+data class ImportReminderPrompt(
+ val currentReminderCount: Int,
+ val defaultReminders: List,
+)
+
/**
* Holds the event form being composed. The form's calendar id resolves to
* (user pick > last used > first writable); the resolved value is what the UI
@@ -78,10 +108,16 @@ class EventEditViewModel @Inject constructor(
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
+ // A one-time offer, raised when a .ics import opens, to replace the file's
+ // reminders with the settings default (#49). Null while there's nothing to ask.
+ private val _importReminderPrompt = MutableStateFlow(null)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow = _loadFailed.asStateFlow()
+ /** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
+ val importReminderPrompt: StateFlow = _importReminderPrompt.asStateFlow()
+
/**
* The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
@@ -112,7 +148,17 @@ class EventEditViewModel @Inject constructor(
val allDay: List,
val timedOverrides: Map>,
val allDayOverrides: Map>,
- )
+ ) {
+ /** The default reminders for an event on [calendarId] of the given kind. */
+ fun resolveFor(calendarId: Long?, isAllDay: Boolean): List = resolveDefaultReminder(
+ timedGlobal = timed,
+ allDayGlobal = allDay,
+ timedOverrides = timedOverrides,
+ allDayOverrides = allDayOverrides,
+ calendarId = calendarId,
+ isAllDay = isAllDay,
+ )
+ }
private data class ExternalInputs(
val writable: List,
@@ -241,18 +287,39 @@ class EventEditViewModel @Inject constructor(
}
/**
- * Seed a fresh event from a parsed `.ics` file (the single-event "open into
- * the create form" path). [form] already carries the file's fields; its
- * [EventForm.calendarId] is null so the calendar still resolves to the
- * last-used/first-writable one, and reminders are frozen as touched so the
- * settings default never overwrites what the file specified. No-op when a
- * form is already open, so the prefill survives configuration changes.
+ * Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
+ * file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
+ * app/widget creating an event, e.g. Google Maps' "add to calendar";
+ * [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
+ * calendar still resolves to the last-used/first-writable one.
+ *
+ * Reminders are handled per [source], because the two paths mean different
+ * things by "no reminders":
+ * - [ImportSource.Insert] carries no reminder semantics, so the settings
+ * default is applied automatically like an in-app new event (a form that
+ * somehow did carry reminders keeps them, frozen).
+ * - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
+ * settings default is configured and differs, [importReminderPrompt] offers
+ * to swap it in rather than silently deciding for the user.
+ *
+ * No-op when a form is already open, so the prefill survives configuration
+ * changes.
*/
- fun openImported(form: EventForm) {
+ fun openImported(form: EventForm, source: ImportSource) {
if (_form.value != null || _editTarget.value != null) return
- _remindersTouched.value = true
_revealed.value = form.populatedFields()
_form.value = form
+ when (source) {
+ ImportSource.Insert ->
+ if (form.reminders.isNotEmpty()) _remindersTouched.value = true
+ else applyDefaultReminder()
+
+ ImportSource.File -> {
+ // Respect the file's own reminders; never silently overwrite them.
+ _remindersTouched.value = true
+ maybePromptImportedReminderDefault(form)
+ }
+ }
}
/**
@@ -266,26 +333,12 @@ class EventEditViewModel @Inject constructor(
private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return
viewModelScope.launch {
- val defaults = combine(
- settingsPrefs.defaultReminderMinutes,
- settingsPrefs.defaultAllDayReminderMinutes,
- settingsPrefs.perCalendarReminderOverride,
- settingsPrefs.perCalendarAllDayReminderOverride,
- ) { timed, allDay, timedOv, allDayOv ->
- ReminderDefaults(timed, allDay, timedOv, allDayOv)
- }.first()
+ val defaults = reminderDefaults()
val targetId = calendarId ?: resolvedCalendarId.first()
// 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(
- timedGlobal = defaults.timed,
- allDayGlobal = defaults.allDay,
- timedOverrides = defaults.timedOverrides,
- allDayOverrides = defaults.allDayOverrides,
- calendarId = targetId,
- isAllDay = form.isAllDay,
- )
+ val reminders = defaults.resolveFor(targetId, form.isAllDay)
_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.
@@ -295,6 +348,52 @@ class EventEditViewModel @Inject constructor(
}
}
+ /** Snapshot the four settings-default reminder flows into one value. */
+ private suspend fun reminderDefaults(): ReminderDefaults = combine(
+ settingsPrefs.defaultReminderMinutes,
+ settingsPrefs.defaultAllDayReminderMinutes,
+ settingsPrefs.perCalendarReminderOverride,
+ settingsPrefs.perCalendarAllDayReminderOverride,
+ ) { timed, allDay, timedOv, allDayOv ->
+ ReminderDefaults(timed, allDay, timedOv, allDayOv)
+ }.first()
+
+ /**
+ * A `.ics` import respects the file's reminders, but an event opened from a
+ * file often has none while the user still expects their configured default.
+ * Rather than silently deciding, raise a one-time offer to swap in the
+ * settings default — but only when there's a real choice: a default is
+ * configured and it isn't already exactly what the file carried.
+ */
+ private fun maybePromptImportedReminderDefault(form: EventForm) {
+ viewModelScope.launch {
+ val targetId = resolvedCalendarId.first()
+ val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
+ // Bail if the form closed or became an edit while we resolved.
+ val current = _form.value ?: return@launch
+ if (_editTarget.value != null) return@launch
+ if (default.isEmpty() || default == current.reminders) return@launch
+ _importReminderPrompt.value = ImportReminderPrompt(
+ currentReminderCount = current.reminders.size,
+ defaultReminders = default,
+ )
+ }
+ }
+
+ /** Accept the import prompt: replace the file's reminders with the default. */
+ fun applyImportedReminderDefault() {
+ val prompt = _importReminderPrompt.value ?: return
+ _importReminderPrompt.value = null
+ // Already frozen as touched by openImported; this just swaps the values.
+ update { it.copy(reminders = prompt.defaultReminders) }
+ _revealed.value = _revealed.value + EventFormField.Reminders
+ }
+
+ /** Decline the import prompt: keep the file's own reminders untouched. */
+ fun dismissImportedReminderPrompt() {
+ _importReminderPrompt.value = null
+ }
+
/**
* Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a
@@ -329,6 +428,7 @@ class EventEditViewModel @Inject constructor(
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
+ _importReminderPrompt.value = null
}
/** Unfold one optional field, picked in the "more fields" dialog. */
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 87dd845..ade055d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -117,6 +117,16 @@
Event deleted
This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.
+
+ Apply your default reminder?
+ This event was imported without any reminder.
+
+ - This event was imported with %1$d reminder.
+ - This event was imported with %1$d reminders.
+
+ Apply default
+ Keep as-is
+
Does not repeat
Custom
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
index 299d2fe..36d397d 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
@@ -32,6 +32,7 @@ class EventDetailMapperTest {
accessLevel: Any? = null,
timezone: String? = null,
selfStatus: Any? = null,
+ duration: String? = null,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
+ EventDetailProjection.IDX_DURATION to duration,
)
private fun attendeeReader(
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
// A yearly birthday/anniversary anchored before the epoch has a
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
- // no DTEND (they use DURATION), so it stays end == begin.
+ // no DTEND (they use DURATION), so the length comes from DURATION.
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
- val detail = detailReader(dtstart = begin, dtend = null).toDetail()
+ val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
- assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
+ assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
+ }
+
+ @Test
+ fun `absent dtend takes its length from DURATION (issue #48)`() {
+ // A recurring series row has no DTEND. Opened without a named
+ // occurrence — a bare content://…/events/ VIEW intent — the row's
+ // own times are what render, so DURATION has to supply the length or
+ // the event shows as zero-length (10:00–10:00).
+ val begin = 1_000_000_000L
+ val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
+ assertThat(detail).isNotNull()
+ assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
+ }
+
+ @Test
+ fun `absent dtend and absent DURATION stays zero-length`() {
+ val begin = 1_000_000_000L
+ val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
+ assertThat(detail).isNotNull()
+ assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
}
@Test
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
index f1be42b..7aa8d78 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
@@ -220,6 +220,117 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
+ // --- buildOccurrenceCancelValues ("delete only this event") ---
+
+ @Test
+ fun `occurrence cancel anchors a single instance and cancels only it`() {
+ val values = buildOccurrenceCancelValues(
+ originalInstanceMillis = 1_700_000_000_000L,
+ dtStartMillis = 1_700_000_000_000L,
+ duration = "P3600S",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
+ .isEqualTo(1_700_000_000_000L)
+ // DTSTART + DURATION make the provider derive a single instance and drop
+ // the inherited RRULE, so only this occurrence is cancelled — not the
+ // whole series (#47). DTEND is never sent (the provider rejects it).
+ assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
+ assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
+ assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
+ assertThat(values[CalendarContract.Events.STATUS])
+ .isEqualTo(CalendarContract.Events.STATUS_CANCELED)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
+ }
+
+ @Test
+ fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
+ val values = buildOccurrenceCancelValues(
+ originalInstanceMillis = 1_700_000_000_000L,
+ dtStartMillis = 1_700_000_000_000L,
+ duration = "P1D",
+ timezone = "UTC",
+ allDay = 1,
+ )
+ assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
+ assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
+ assertThat(values[CalendarContract.Events.STATUS])
+ .isEqualTo(CalendarContract.Events.STATUS_CANCELED)
+ }
+
+ // --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
+
+ @Test
+ fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
+ // 2026-07-15T08:00:00Z.
+ val values = buildOccurrenceExdateValues(
+ existingExdate = null,
+ occurrenceMillis = 1_784_102_400_000L,
+ dtStartMillis = 1_783_929_600_000L,
+ rrule = "FREQ=DAILY;COUNT=5",
+ duration = "PT1H",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
+ // The whole time/recurrence set rides along: an EXDATE-only update is not
+ // treated as a recurrence change, so the provider would leave the expanded
+ // instances (and the occurrence) in place. DTSTART alone is worse — it
+ // makes the provider recompute lastDate as a single instance and collapse
+ // the series to its first occurrence.
+ assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
+ assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
+ assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
+ assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
+ }
+
+ @Test
+ fun `exdate drop appends to an existing exdate list`() {
+ val values = buildOccurrenceExdateValues(
+ existingExdate = "20260714T080000Z",
+ occurrenceMillis = 1_784_102_400_000L,
+ dtStartMillis = 1_783_929_600_000L,
+ rrule = "FREQ=DAILY;COUNT=5",
+ duration = "PT1H",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE])
+ .isEqualTo("20260714T080000Z,20260715T080000Z")
+ }
+
+ @Test
+ fun `exdate drop folds away a repeated occurrence`() {
+ val values = buildOccurrenceExdateValues(
+ existingExdate = "20260715T080000Z",
+ occurrenceMillis = 1_784_102_400_000L,
+ dtStartMillis = 1_783_929_600_000L,
+ rrule = "FREQ=DAILY;COUNT=5",
+ duration = "PT1H",
+ timezone = "Europe/Berlin",
+ allDay = 0,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
+ }
+
+ @Test
+ fun `all-day exdate drop uses the date-only form`() {
+ // An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
+ val values = buildOccurrenceExdateValues(
+ existingExdate = null,
+ occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
+ dtStartMillis = 1_783_900_800_000L,
+ rrule = "FREQ=YEARLY",
+ duration = "P1D",
+ timezone = "UTC",
+ allDay = 1,
+ )
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
+ assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
+ }
+
// --- per-event colour ---
@Test
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt
new file mode 100644
index 0000000..18d0aa7
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt
@@ -0,0 +1,214 @@
+package de.jeanlucmakiola.calendula.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class EventColorPaletteTest {
+
+ @Test
+ fun `empty palette stays empty`() {
+ assertThat(emptyList().curatedForPicker()).isEmpty()
+ }
+
+ @Test
+ fun `exact duplicate values collapse to the alphabetically first key`() {
+ val curated = listOf(
+ EventColorOption("cyan", 0xFF00FFFF.toInt()),
+ EventColorOption("aqua", 0xFF00FFFF.toInt()),
+ EventColorOption("red", 0xFFFF0000.toInt()),
+ ).curatedForPicker()
+
+ assertThat(curated.map { it.key }).containsExactly("aqua", "red")
+ }
+
+ @Test
+ fun `small palettes pass through whole, so Google's curated set is untouched`() {
+ // A Google-like palette: two dozen distinct hand-picked colours.
+ val palette = (0 until 24).map {
+ val hue = it * 15
+ EventColorOption("$it", hsvArgb(hue.toFloat()))
+ }
+
+ val curated = palette.curatedForPicker()
+
+ assertThat(curated).containsExactlyElementsIn(palette)
+ }
+
+ @Test
+ fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
+ val curated = css3Palette().curatedForPicker()
+
+ // The whole point of #22: ~147 published colours become a single
+ // manageable grid instead of a full screen.
+ assertThat(curated.size).isAtLeast(30)
+ assertThat(curated.size).isAtMost(60)
+ }
+
+ @Test
+ fun `curation never invents colours or drops keys`() {
+ val source = css3Palette()
+ val curated = source.curatedForPicker()
+
+ assertThat(source).containsAtLeastElementsIn(curated)
+ assertThat(curated.map { it.argb }).containsNoDuplicates()
+ }
+
+ @Test
+ fun `spelling-alias pairs never both survive`() {
+ val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
+
+ val aliasPairs = listOf(
+ "aqua" to "cyan",
+ "fuchsia" to "magenta",
+ "gray" to "grey",
+ "darkgray" to "darkgrey",
+ "dimgray" to "dimgrey",
+ "lightgray" to "lightgrey",
+ "slategray" to "slategrey",
+ "lightslategray" to "lightslategrey",
+ "darkslategray" to "darkslategrey",
+ )
+ aliasPairs.forEach { (a, b) ->
+ assertThat(keys.contains(a) && keys.contains(b)).isFalse()
+ }
+ }
+
+ @Test
+ fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
+ // Black and every gray paint as the same pale swatch (the picker pins
+ // lightness and floors saturation), so only one survives — no stranded
+ // run of look-alike "pinks" at the end of the grid (#22).
+ val curated = listOf(
+ EventColorOption("black", 0xFF000000.toInt()),
+ EventColorOption("gray", 0xFF808080.toInt()),
+ EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
+ EventColorOption("blue", 0xFF0000FF.toInt()),
+ EventColorOption("red", 0xFFFF0000.toInt()),
+ ).curatedForPicker().map { it.key }
+
+ assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
+ assertThat(curated).containsAtLeast("black", "red", "blue")
+ }
+
+ @Test
+ fun `a dark and a light shade of one hue collapse to a single swatch`() {
+ // The picker paints every swatch at one fixed lightness, so navy and a
+ // mid blue are indistinguishable once painted — keep just one.
+ val curated = listOf(
+ EventColorOption("navy", 0xFF000080.toInt()),
+ EventColorOption("blue", 0xFF0000FF.toInt()),
+ ).curatedForPicker()
+
+ assertThat(curated).hasSize(1)
+ }
+
+ @Test
+ fun `the wheel is cut once, keeping each hue family contiguous`() {
+ // Twelve pure hues, deliberately shuffled; a small palette passes the
+ // thinning stage untouched so only the ordering is under test.
+ val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
+ val curated = shuffledHues
+ .map { EventColorOption("$it", hsvArgb(it.toFloat())) }
+ .curatedForPicker()
+ .map { it.key.toInt() }
+
+ // A proper single-seam sweep around the wheel descends exactly once
+ // (at the seam). The old bucketed sort could scatter a family across
+ // both ends, producing extra descents.
+ val descents = curated.indices.count { i ->
+ curated[(i + 1) % curated.size] < curated[i]
+ }
+ assertThat(descents).isEqualTo(1)
+ }
+
+ @Test
+ fun `CSS3 survivors span the whole rainbow`() {
+ val keys = css3Palette().curatedForPicker().map { it.key }
+ fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
+
+ // Which exact name represents a hue family depends on the vivid-first
+ // thinning, so assert each family survives, not a specific key.
+ assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
+ assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
+ assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
+ assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
+ assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
+ }
+
+ private fun hsvArgb(hue: Float): Int {
+ val h = hue / 60f
+ val sector = h.toInt() % 6
+ val f = h - h.toInt()
+ val q = ((1 - f) * 255).toInt()
+ val t = (f * 255).toInt()
+ return when (sector) {
+ 0 -> argb(255, t, 0)
+ 1 -> argb(q, 255, 0)
+ 2 -> argb(0, 255, t)
+ 3 -> argb(0, q, 255)
+ 4 -> argb(t, 0, 255)
+ else -> argb(255, 0, q)
+ }
+ }
+
+ private fun argb(r: Int, g: Int, b: Int): Int =
+ (0xFF shl 24) or (r shl 16) or (g shl 8) or b
+
+ /** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
+ private fun css3Palette(): List = CSS3.map { (name, rgb) ->
+ EventColorOption(name, 0xFF000000.toInt() or rgb)
+ }
+
+ private val CSS3 = mapOf(
+ "aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
+ "aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
+ "bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
+ "blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
+ "burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
+ "chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
+ "cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
+ "darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
+ "darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
+ "darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
+ "darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
+ "darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
+ "darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
+ "darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
+ "dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
+ "firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
+ "fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
+ "gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
+ "green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
+ "honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
+ "indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
+ "lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
+ "lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
+ "lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
+ "lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
+ "lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
+ "lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
+ "lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
+ "linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
+ "mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
+ "mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
+ "mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
+ "mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
+ "mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
+ "navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
+ "olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
+ "orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
+ "paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
+ "peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
+ "plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
+ "red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
+ "saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
+ "seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
+ "silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
+ "slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
+ "springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
+ "teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
+ "turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
+ "white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
+ "yellowgreen" to 0x9ACD32,
+ )
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/21401.txt b/fastlane/metadata/android/en-US/changelogs/21401.txt
new file mode 100644
index 0000000..9f7f487
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/21401.txt
@@ -0,0 +1,34 @@
+### Fixed
+- Deleting one occurrence of a repeating event no longer breaks the series.
+ Choosing "This event" when deleting an occurrence of a recurring event could
+ wipe out every *other* occurrence while leaving the one you deleted behind as
+ a stale, still-tappable ghost — and deleting it again brought the series back.
+ A single-occurrence delete now removes exactly that occurrence and leaves the
+ rest of the series untouched, and the deleted occurrence disappears from the
+ grid straight away. This holds on every kind of calendar, including the
+ on-device ones Calendula keeps for contact birthdays and anniversaries, where
+ the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
+- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
+ Calendula to answer the "new event" hand-off from other apps and widgets; now
+ it also answers the "open this event" one, so tapping an existing event in a
+ widget such as Todo Agenda offers Calendula and lands on that event's details.
+ Thanks to @bushrang3r for the report ([#48]).
+- Events created from other apps get your default reminder. An event handed over
+ by another app or widget — Google Maps' "add to calendar", the Todo Agenda
+ widget's "+" — opened with no reminder at all, ignoring the default set in
+ Settings. It now starts with your default reminder, the same as an event you
+ create in Calendula. An event opened from an `.ics` file is treated differently,
+ because the file has its own say: Calendula keeps whatever reminders it carries
+ (including none at all) and asks you once whether to apply your default instead
+ — it never quietly overrides the file. If you have no default set, it doesn't
+ ask ([#49]).
+- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
+ app (such as DAVx5), the event colour picker showed every colour the account
+ publishes — nearly 150 swatches in alphabetical order, many of them
+ duplicates or near-identical shades. The picker now shows only visually
+ distinct colours, arranged as a rainbow; near-duplicate shades and the
+ washed-out neutrals are folded away so no two swatches look alike. Picked
+ colours still sync exactly as before, and calendars with hand-picked
+ palettes (like Google's) are unaffected. Thanks to @ptab for the report
+ ([#22]).
+