diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1069d2b..522d6f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
+## [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
@@ -889,6 +925,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
@@ -901,3 +938,6 @@ automatically, with zero telemetry and no internet permission.
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
+[#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 9dd6112..ac0b56e 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
@@ -24,6 +24,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
@@ -65,10 +66,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
@@ -513,7 +516,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()
}
@@ -632,7 +641,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()
}
@@ -1175,6 +1184,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 ->
@@ -1185,6 +1196,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
@@ -1197,6 +1210,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(
@@ -1387,16 +1403,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 a8ad518..9758587 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
@@ -306,6 +306,100 @@ internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map` 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 0ab8f79..774e19b 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 ad5b9fb..838930f 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))
+ }
+ },
+ )
}
/**
@@ -1748,12 +1802,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 2de8b1e..72ea3f5 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-de/strings.xml b/app/src/main/res/values-de/strings.xml
index d415ff9..23996c0 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -310,7 +310,7 @@
App-Sprache
Systemstandard
- Design, Standartansicht, Wochenstart
+ Design, Standardansicht, Wochenstart
Standardfelder für neue Termine
Termin-Erinnerungen
Über
@@ -474,4 +474,20 @@
Geburtstag von {name} ({year})
Jahrestag von {name} ({year})
{name}
+ Kalenderwochen
+ Kalenderwochen der Monatsansicht anzeigen
+ Kalender exportieren
+ Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.
+ Exportieren
+ Wiederherstellen
+ Aus .ics-Datei wiederherstellen
+ Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.
+ Bereits im Kalender vorhandene Ereignisse wurden übersprungen.
+ Hinzugefügt
+ Duplikate
+ Importieren
+
+ - %d Ereignis wird importiert
+ - %d Ereignisse werden importiert
+
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index b9712ae..ee06261 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -16,17 +16,17 @@
Nessun calendario configurato.
Apri le impostazioni calendario di sistema
Errore nella lettura del calendario.
- Guarda tutti i tuoi eventi, magnificamente
- Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.
+ Gestisci magnificamente tutti i tuoi eventi
+ Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.
Concedi l\'accesso al calendario
Accesso al calendario negato
Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.
Apri le impostazioni di sistema
Riprova
Rimane sul tuo dispositivo
- I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.
+ I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.
Tutti i tuoi calendari, insieme
- Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.
+ Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.
Nessun tracking, per sempre
Zero telemetria, zero analytics, zero pubblicità.
Rimane sul tuo dispositivo - nessun accesso a internet
@@ -38,7 +38,7 @@
Impostazioni
Oggi
Questa settimana
- Wk
+ Sett.
Oggi
Indietro
Modifica
@@ -73,14 +73,14 @@
Rimuovi promemoria
Partecipanti
Aggiungi partecipante
- Aggiungi un partecipante via email…
+ Aggiungi un partecipante tramite indirizzo email…
Aggiungi dai contatti
Scegli un indirizzo dai contatti
Rimuovi partecipante
Obbligatorio
Facoltativo
Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.
- Salvato sul dispositivo. Nessuno ha ricevuto notifiche.
+ Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.
Personalizza
minuti
ore
@@ -89,7 +89,7 @@
Disponibilità
Visibilità
Colore
- Colore calendario
+ Colore del calendario
Colore personalizzato
Reset
Non disponibile per questo calendario
@@ -99,13 +99,13 @@
Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?
Salva le modifiche
Sovrascrivi solo i campi modificati
- Annulla le mie modifiche
- Mantieni l\'evento com\'è ora
+ Annulla le modifiche
+ Lascia l\'evento invariato
Evento eliminato
Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.
Non si ripete
Personalizza
- La serie
+ Ogni
giorni
settimane
mesi
@@ -113,7 +113,7 @@
Fine
Mai
Fino alla data
- Dopo un certo numero di occorrenze
+ Numero di occorrenze
occorrenze
Fine antecedente l\'inizio
Occupato
@@ -125,7 +125,7 @@
Luogo
Descrizione
Partecipanti
- Ogni giorni
+ Ogni giorno
Ogni settimana
Ogni mese
Ogni anno
@@ -204,7 +204,7 @@
Lo switch è nelle Impostazioni, sezione Notifiche.
Attiva i promemoria
Non ora
- Posponi
+ Posticipa
Ignora
Mese
Settimana
@@ -418,16 +418,16 @@
Promemoria standard
Promemoria evento
Nuovo evento
- Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente
- Carattere dell\'intestazione
+ Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.
+ Carattere del titolo
Carattere del corpo
Predefinito di sistema
Scegli file…
- Carattere personalizzato
+ Font personalizzato
Impossibile leggere il file come font
Viste
Pulsante di cambio rapido
- Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.
+ Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.
Menù di navigazione
Trascina per riordinare le viste elencate nel menù di navigazione.
Trascina per riordinare
@@ -463,4 +463,7 @@
Date speciali
Compleanno di {name} ({year})
Anniversario di {name} ({year})
+ Lora
+ JetBrains Mono
+ {name}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9966ef9..5683820 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 85d8956..972b9d9 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]).
+