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