From 2218c11d3f09bfc9b2d8a364b98372ec1b848297 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 10:36:35 +0200 Subject: [PATCH 01/11] fix: cancel only the tapped occurrence on single-instance delete (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Delete only this event" on a recurring series wrote a cancelled exception carrying just ORIGINAL_INSTANCE_TIME + STATUS_CANCELED. Without DTSTART + DURATION the provider clones the master *with its RRULE intact* and cancels the whole clone, so every other occurrence vanished, the target survived as a "cancelled" ghost, and re-deleting toggled the series back — exactly the reported corruption. Anchor the exception as a single instance (DTSTART + DURATION + zone + all-day, read from the series row) so the provider clears the inherited RRULE and cancels only that occurrence — the same discipline the edit path already documents (Codeberg #16). Also filter STATUS_CANCELED out of the instances grid query so the cancelled occurrence disappears instead of lingering as a tappable ghost (NULL status is kept — a normal event). Extracts the exception ContentValues into a pure buildOccurrenceCancelValues helper with JVM tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 30 ++++++++++---- .../data/calendar/EventWriteMapper.kt | 27 +++++++++++++ .../data/calendar/EventWriteMapperTest.kt | 40 +++++++++++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) 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..632557e 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 @@ -492,7 +492,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() } @@ -1183,15 +1189,25 @@ 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) - } + // adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to + // carry the full time set (DTSTART + DURATION + zone), not just STATUS: + // the provider only demotes the cloned exception to a single instance — + // clearing the inherited RRULE — when it can derive that instance from + // those columns. A STATUS-only cancel left the RRULE standing and + // cancelled the whole series, wiping every other occurrence (#47), the + // same trap the edit path documents (Codeberg #16). + val row = querySeriesRow(eventId) + 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/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index dc830aa..e310c48 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,33 @@ 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 `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/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index f1be42b..252ca37 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,46 @@ 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) + } + // --- per-event colour --- @Test -- 2.49.1 From 9d718e0f512ede198cf10711550a76ae17aee694 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:01:53 +0200 Subject: [PATCH 02/11] fix: open existing events from external VIEW intents (#48) Follow-up to #30. v2.14.0 handles ACTION_INSERT (the widget "+"), but tapping an existing event in a third-party widget (e.g. Todo Agenda) never offered Calendula, because nothing handled ACTION_VIEW on content://com.android.calendar/events/. - Manifest: add a VIEW intent-filter matched by the provider's item MIME type (vnd.android.cursor.item/event), mirroring AOSP Calendar and the sibling INSERT dir/event filter. A content: VIEW intent carries the resolved type, so a path-only filter wouldn't match it. - MainActivity.viewEventKeyOrNull: parse the events URI into the existing occurrence detail-key channel (the one reminder taps use). Occurrence times ride as EXTRA_EVENT_BEGIN_TIME/END_TIME when the launcher supplies them; a bare URI omits them. - EventDetailViewModel: a NO_OCCURRENCE_TIME sentinel makes loadDetail keep the event row's own DTSTART/DTEND for a bare URI instead of overriding to the epoch (would otherwise render at 1970). Needs on-device verification (intent-filter matching + the widget's actual extras). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 16 ++++++++++ .../jeanlucmakiola/calendula/MainActivity.kt | 29 +++++++++++++++-- .../ui/detail/EventDetailViewModel.kt | 31 ++++++++++++++----- 3 files changed, 67 insertions(+), 9 deletions(-) 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/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). */ -- 2.49.1 From b6bcd195b052ff01f2794fc6d75342622d792a34 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:20:07 +0200 Subject: [PATCH 03/11] fix(edit): curate the CalDAV colour picker (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into CalendarContract.Colors, so the event-colour picker showed a full screen of alphabetically-scrambled, partly duplicated swatches. Curation now runs in the space the picker actually paints — every swatch is softened through pastelize, which pins lightness and caps saturation, so the raw palette's lightness axis is invisible on screen. Judging distinctness there: colours that paint identically collapse to one (folding aliases, dark/light shades of a hue, and the neutrals together), oversized palettes drop washed-out neutral-origin tints and thin by CIE76 ΔE in painted Lab, and survivors sort continuously by painted hue with the wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct, rainbow-ordered swatches; small hand-picked palettes (Google's) pass through untouched. Every surviving swatch keeps its provider colour key so picks still round-trip through sync. This revives work stranded on fix/caldav-color-picker (never merged) and adapts it to the floret-kit extraction of pastelize: the curation's painted-space transform now lives self-contained in domain/pastelArgb as a mirror of floret's pastelize shaping, rather than the two sharing one function. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ .../data/calendar/CalendarDataSource.kt | 13 +- .../calendula/domain/EventColorPalette.kt | 185 +++++++++++++++ .../calendula/ui/edit/EventEditScreen.kt | 15 +- .../calendula/domain/EventColorPaletteTest.kt | 214 ++++++++++++++++++ 5 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d8da9d1..748ecc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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] + +### Fixed +- 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 +878,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 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 632557e..84a3e2a 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 @@ -617,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() } 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/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 66944f1..1e13366 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 @@ -1747,12 +1747,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/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, + ) +} -- 2.49.1 From 4f263d00fe6616a9ebbc51ed532d437e42249498 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:46:51 +0200 Subject: [PATCH 04/11] fix(edit): apply default reminder to ACTION_INSERT events (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External ACTION_INSERT launches (e.g. Google Maps' "add to calendar", the Todo Agenda widget) share the single-event .ics prefill channel: CalendarHost routes requestedInsertForm as importForm, so EventEditScreen calls openImported(), which froze reminders as touched to respect a file's own VALARMs. But an insert intent carries no reminders, so the empty freeze just suppressed the configured settings default — the event opened (and saved) with no reminder. Make the freeze follow the source, not the path: a form that carries its own reminders (an .ics with VALARMs) still freezes them; a form with none (every insert intent, and an .ics without VALARMs) falls back to the settings default via applyDefaultReminder(), exactly like openNew(). An intent that did carry reminders still wins. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditViewModel.kt | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) 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..fc7dc31 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 @@ -241,18 +241,31 @@ 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 or an external `ACTION_INSERT` intent (another app/widget creating an + * event, e.g. Google Maps' "add to calendar"; #30, #49). [EventForm.calendarId] + * is null so the calendar still resolves to the last-used/first-writable one. + * + * Reminders follow the source rather than the path: a form that carries its + * own (an `.ics` with VALARMs) freezes them as touched, so the settings + * default never overwrites what it specified; a form with none — every + * insert intent, and an `.ics` without VALARMs — falls back to the settings + * default exactly like [openNew], instead of the empty freeze suppressing it. + * No-op when a form is already open, so the prefill survives configuration + * changes. */ fun openImported(form: EventForm) { if (_form.value != null || _editTarget.value != null) return - _remindersTouched.value = true _revealed.value = form.populatedFields() _form.value = form + if (form.reminders.isNotEmpty()) { + // Source specified its own reminders — freeze so the default can't + // clobber them (and a later calendar/all-day switch won't re-apply). + _remindersTouched.value = true + } else { + // Nothing carried: inherit the settings default like a new event. + applyDefaultReminder() + } } /** -- 2.49.1 From 6aacdd91113a6c8bf5c804bfd2e1bc9b05251217 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:57:38 +0200 Subject: [PATCH 05/11] feat(edit): offer default reminder on .ics import instead of auto-applying (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the two prefill paths that share openImported(): an ACTION_INSERT intent still auto-applies the settings default (it carries no reminder semantics), but a .ics file — which owns its reminders — no longer silently decides. It keeps the file's reminders and raises a one-time prompt ("This event was imported with N reminder(s) — apply your default?") so the user chooses. The prompt is skipped when there's no real choice: no default configured, or the file already carries exactly it. openImported() now takes an ImportSource; CalendarHost tags the overlay Insert vs File. Accepting swaps in the default and reveals the section; declining (or dismissing) keeps the file's own reminders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/CalendarHost.kt | 7 + .../calendula/ui/edit/EventEditScreen.kt | 58 ++++++- .../calendula/ui/edit/EventEditViewModel.kt | 153 ++++++++++++++---- app/src/main/res/values/strings.xml | 10 ++ 4 files changed, 193 insertions(+), 35 deletions(-) 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/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 1e13366..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)) + } + }, + ) } /** 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 fc7dc31..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, @@ -242,29 +288,37 @@ class EventEditViewModel @Inject constructor( /** * Seed a fresh event from a prefilled [form] — a parsed single-event `.ics` - * file or an external `ACTION_INSERT` intent (another app/widget creating an - * event, e.g. Google Maps' "add to calendar"; #30, #49). [EventForm.calendarId] - * is null so the calendar still resolves to the last-used/first-writable one. + * 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. * - * Reminders follow the source rather than the path: a form that carries its - * own (an `.ics` with VALARMs) freezes them as touched, so the settings - * default never overwrites what it specified; a form with none — every - * insert intent, and an `.ics` without VALARMs — falls back to the settings - * default exactly like [openNew], instead of the empty freeze suppressing it. * 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 _revealed.value = form.populatedFields() _form.value = form - if (form.reminders.isNotEmpty()) { - // Source specified its own reminders — freeze so the default can't - // clobber them (and a later calendar/all-day switch won't re-apply). - _remindersTouched.value = true - } else { - // Nothing carried: inherit the settings default like a new event. - applyDefaultReminder() + 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) + } } } @@ -279,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. @@ -308,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 @@ -342,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 -- 2.49.1 From e6def9e5f7a0a3a06ac6dfae468087475692351a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 12:34:08 +0200 Subject: [PATCH 06/11] docs(changelog): log the #47/#48/#49 fixes under Unreleased The 2.14.1 branch carried four fixes but only the CalDAV colour picker (#22) had a changelog entry. Adds the recurring single-instance delete fix (#47), the external VIEW-intent handling (#48) and the default reminder on ACTION_INSERT / .ics import prompt (#49), plus their issue link definitions. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 748ecc5..8d7f4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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. 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. Events imported from an `.ics` file still keep the + reminders the file carries; when the file brings its own, Calendula asks once + whether to apply your default instead of deciding for you ([#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 @@ -888,3 +907,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 -- 2.49.1 From cf9492c7ba257cc259b950c2f3de74d6745fed76 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 12:55:05 +0200 Subject: [PATCH 07/11] fix(detail): derive series length from DURATION when DTEND is null (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recurring series row carries DURATION, not DTEND, so EventDetailMapper's end == begin fallback rendered it zero-length. That was harmless while every caller supplied per-occurrence times from Instances, but the bare content://…/events/ VIEW intent added in #48 names no occurrence and keeps the row's own times — so a series opened from a third-party widget without begin/end extras showed as "10:00 – 10:00". Read DURATION in the detail projection and derive the end from it, the same way SearchMapper and IcsExportMapper already do. Co-Authored-By: Claude Opus 4.8 --- .../data/calendar/EventDetailMapper.kt | 20 +++++++------ .../calendula/data/calendar/Projections.kt | 4 +++ .../data/calendar/EventDetailMapperTest.kt | 28 +++++++++++++++++-- 3 files changed, 41 insertions(+), 11 deletions(-) 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/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/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 -- 2.49.1 From 4fea176e28804f387c11a58ba5ebaf9c655c105a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 15:01:11 +0200 Subject: [PATCH 08/11] fix: drop occurrences via EXDATE on calendars with no _sync_id (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cancelled-exception fix works on synced calendars but not local ones. A cancelled exception only attaches to its parent through ORIGINAL_SYNC_ID; a local event has no _sync_id, so the link never forms and the provider's expansion of the *parent* collapses — every other occurrence disappears, which is the original #47 corruption, just on a different calendar type. Verified on-device both ways: a DAVx5 series survives a single-occurrence delete, the same series on a LOCAL calendar vanishes entirely. deleteOccurrence now branches on _sync_id. Synced events keep the (verified) exception path. Events without one — local calendars, and synced events not yet pushed — add the occurrence to the master's EXDATE, which needs no parent link and is the canonical iCalendar way to drop one; a sync adapter carries it upstream unchanged if the calendar later syncs. Two provider quirks shape the write (both observed on a Pixel): - An EXDATE-only update is not treated as a recurrence change: the expanded Instances rows are left alone, so the occurrence stays visible. The time/recurrence set has to ride along to force re-expansion. - DTSTART alone is worse — the provider then recomputes lastDate as if the event were a single instance and collapses the series to its first occurrence. DTSTART + DURATION + RRULE + zone together re-expand it correctly. This path is reached in normal use: Calendula's own contact special-date calendars are local and hold all-day yearly series, so deleting one birthday occurrence went through it. All-day series take the VALUE=DATE EXDATE form. Adds pure buildOccurrenceExdateValues + JVM tests (timed, append, duplicate fold, all-day). Co-Authored-By: Claude Opus 4.8 --- .../data/calendar/CalendarDataSource.kt | 46 +++++++++--- .../data/calendar/EventWriteMapper.kt | 67 +++++++++++++++++ .../data/calendar/EventWriteMapperTest.kt | 71 +++++++++++++++++++ 3 files changed, 176 insertions(+), 8 deletions(-) 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 84a3e2a..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 @@ -979,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 -> @@ -989,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 @@ -1001,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( @@ -1191,15 +1198,38 @@ 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. It has to - // carry the full time set (DTSTART + DURATION + zone), not just STATUS: - // the provider only demotes the cloned exception to a single instance — - // clearing the inherited RRULE — when it can derive that instance from - // those columns. A STATUS-only cancel left the RRULE standing and - // cancelled the whole series, wiping every other occurrence (#47), the - // same trap the edit path documents (Codeberg #16). 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, 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 e310c48..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 @@ -209,6 +209,73 @@ internal fun buildOccurrenceCancelValues( 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/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 252ca37..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 @@ -260,6 +260,77 @@ class EventWriteMapperTest { .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 -- 2.49.1 From 9c7c8cb03ab3167526ce2e39a973d1e9cddb08d7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 15:01:37 +0200 Subject: [PATCH 09/11] docs(changelog): note the #47 fix covers local calendars too Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7f4c1..3493996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. Thanks to @moonj for the report ([#47]). + 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 -- 2.49.1 From 53793bfb68d4bf759fdf12a859275ac01c55fb92 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 15:24:18 +0200 Subject: [PATCH 10/11] docs(changelog): describe the .ics reminder prompt accurately (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt is raised whenever a default is configured and the file's reminders differ from it — including when the file carries none at all, not only when it brings its own. On-device verified. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3493996..05aa662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. Events imported from an `.ics` file still keep the - reminders the file carries; when the file brings its own, Calendula asks once - whether to apply your default instead of deciding for you ([#49]). + 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 -- 2.49.1 From 34fa9c9c3e3da48688252f5a0d389bee3f1a7d34 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 15:32:16 +0200 Subject: [PATCH 11/11] chore(release): cut 2.14.1 Move the Unreleased fixes (#47, #48, #49, #22) under a 2.14.1 heading, bump versionName/versionCode to 2.14.1/21401, and sync the F-Droid per-version changelog. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ app/build.gradle.kts | 4 +-- .../android/en-US/changelogs/21401.txt | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21401.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 05aa662..4762245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 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/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]). + -- 2.49.1