Compare commits

...

15 Commits

Author SHA1 Message Date
34fa9c9c3e chore(release): cut 2.14.1
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m43s
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 <noreply@anthropic.com>
2026-07-13 15:32:16 +02:00
53793bfb68 docs(changelog): describe the .ics reminder prompt accurately (#49)
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 <noreply@anthropic.com>
2026-07-13 15:24:18 +02:00
9c7c8cb03a docs(changelog): note the #47 fix covers local calendars too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:01:37 +02:00
4fea176e28 fix: drop occurrences via EXDATE on calendars with no _sync_id (#47)
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 <noreply@anthropic.com>
2026-07-13 15:01:11 +02:00
cf9492c7ba fix(detail): derive series length from DURATION when DTEND is null (#48)
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/<id> 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 <noreply@anthropic.com>
2026-07-12 12:55:05 +02:00
e6def9e5f7 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 <noreply@anthropic.com>
2026-07-12 12:34:08 +02:00
974185f354 Merge pull request 'fix(edit): default reminder for ACTION_INSERT events + import prompt (Codeberg #49)' (!71) from fix/insert-intent-default-reminder into release/v2.14.1
Reviewed-on: #71
2026-07-12 10:03:36 +00:00
6aacdd9111 feat(edit): offer default reminder on .ics import instead of auto-applying (#49)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m21s
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) <noreply@anthropic.com>
2026-07-12 11:57:38 +02:00
4f263d00fe fix(edit): apply default reminder to ACTION_INSERT events (#49)
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) <noreply@anthropic.com>
2026-07-12 11:46:51 +02:00
bda002684c Merge pull request 'fix(edit): curate the CalDAV colour picker (#22)' (!70) from fix/caldav-color-picker-v2 into release/v2.14.1
Reviewed-on: #70
2026-07-12 09:40:18 +00:00
b6bcd195b0 fix(edit): curate the CalDAV colour picker (#22)
All checks were successful
CI / ci (pull_request) Successful in 5m18s
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) <noreply@anthropic.com>
2026-07-12 11:22:04 +02:00
9fb4502ed0 Merge pull request 'fix: open existing events from external VIEW intents (Codeberg #48)' (!69) from fix/widget-view-event-intent into release/v2.14.1
Reviewed-on: #69
2026-07-12 09:09:24 +00:00
d398c72005 Merge pull request 'fix: cancel only the tapped occurrence on single-instance recurring delete (Codeberg #47)' (!68) from fix/recurring-single-delete into release/v2.14.1
Reviewed-on: #68
2026-07-12 09:09:07 +00:00
9d718e0f51 fix: open existing events from external VIEW intents (#48)
All checks were successful
CI / ci (pull_request) Successful in 10m20s
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/<id>.

- 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) <noreply@anthropic.com>
2026-07-12 11:01:53 +02:00
2218c11d3f fix: cancel only the tapped occurrence on single-instance delete (#47)
All checks were successful
CI / ci (pull_request) Successful in 6m21s
"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) <noreply@anthropic.com>
2026-07-12 10:36:35 +02:00
18 changed files with 1061 additions and 64 deletions

View File

@@ -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/), 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). 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 ## [2.14.0] — 2026-07-06
### Added ### Added
@@ -865,6 +903,7 @@ automatically, with zero telemetry and no internet permission.
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18 [#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19 [#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20 [#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24 [#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#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 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#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

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21400 versionCode = 21401
versionName = "2.14.0" versionName = "2.14.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -123,6 +123,22 @@
<data android:mimeType="vnd.android.cursor.dir/event" /> <data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter> </intent-filter>
<!-- Open an existing event another app/widget points at (e.g. tapping
an event in the Todo Agenda widget): ACTION_VIEW on
content://com.android.calendar/events/<id>, the way AOSP fires it.
Matched by the provider's item MIME type, not the path — a
content: VIEW intent carries the resolved type
(vnd.android.cursor.item/event) and a path-only filter wouldn't
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
EXTRA_EVENT_END_TIME when the launcher supplies them
(MainActivity.viewEventKeyOrNull, issue #48). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). --> <!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"

View File

@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.floret.components.DebugRibbon import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.FontRole
@@ -90,7 +91,7 @@ class MainActivity : AppCompatActivity() {
} }
enableEdgeToEdge() enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull() requestedInsertForm = intent.insertFormOrNull()
@@ -178,7 +179,7 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it } (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it } intent.insertFormOrNull()?.let { requestedInsertForm = it }
@@ -293,6 +294,30 @@ class MainActivity : AppCompatActivity() {
) )
} }
/**
* The detail key for an external "open this event" — ACTION_VIEW on
* `content://com.android.calendar/events/<id>`, 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 { companion object {
// The calendar provider's authority/host. A date tap arrives as // The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>. // ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
@@ -63,10 +64,12 @@ interface CalendarDataSource {
/** /**
* The event-colour palette the calendar's account publishes * The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the * (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
* account exposes no palette (most local calendars, some CalDAV) — the * thinned to visually distinct swatches when oversized (CalDAV adapters
* signal that a custom colour can only be written as a raw `EVENT_COLOR`, * publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* which a synced calendar may drop on its next sync. * 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<EventColorOption> fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -492,7 +495,13 @@ class AndroidCalendarDataSource @Inject constructor(
return resolver.query( return resolver.query(
uri, uri,
InstanceProjection.COLUMNS, 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", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.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)) } c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
} }
?.filter { it.key.isNotEmpty() } ?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key } ?.curatedForPicker()
?: emptyList() ?: emptyList()
} }
@@ -970,6 +979,8 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.DURATION, CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY, CalendarContract.Events.ALL_DAY,
CalendarContract.Events._SYNC_ID,
CalendarContract.Events.EXDATE,
), ),
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -980,6 +991,8 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = c.getString(2), timezone = c.getString(2),
duration = c.getString(3), duration = c.getString(3),
allDay = c.getInt(4), allDay = c.getInt(4),
syncId = c.getString(5),
exdate = c.getString(6),
) )
} else { } else {
null null
@@ -992,6 +1005,9 @@ class AndroidCalendarDataSource @Inject constructor(
val timezone: String?, val timezone: String?,
val duration: String?, val duration: String?,
val allDay: Int, 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]. */ /** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis( fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
@@ -1182,16 +1198,49 @@ class AndroidCalendarDataSource @Inject constructor(
} }
override fun deleteOccurrence(eventId: Long, beginMillis: Long) { override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
// A cancelled exception row hides exactly this occurrence; the sync val row = querySeriesRow(eventId)
// adapter turns it into an EXDATE/cancelled VEVENT upstream. if (row.syncId == null) {
val values = ContentValues().apply { // No _sync_id — a local calendar, or a synced event not pushed yet.
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis) // A cancelled exception can only attach to its parent through
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) // 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( val uri = ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId, CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
) )
resolver.insert(uri, values) resolver.insert(uri, values.toContentValues())
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis") ?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
} }

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
val begin = getLong(EventDetailProjection.IDX_DTSTART) val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's // Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a // DTEND is null — derive the length from DURATION (as SearchMapper and
// specific occurrence supply the real per-occurrence times from // IcsExportMapper do). Callers that opened a specific occurrence overwrite
// CalendarContract.Instances. A present-but-backwards DTEND is malformed, // both times with the per-occurrence values from CalendarContract.Instances;
// but dropping the row would make the event un-openable — the same trap as // a caller that names no occurrence (a bare content://.../events/<id> VIEW
// the pre-1970 DTSTART bug above (issue #34): it would surface as the // intent, issue #48) keeps this row's own times, so the length has to be
// generic error screen with no way to open the event and fix it. Clamp to a // right here or the series renders zero-length. A present-but-backwards
// zero-length event instead (matching SearchMapper's coerceAtLeast). // 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)) { val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
} else { } else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin) getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
} }

View File

@@ -182,6 +182,100 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) 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/<id>` 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<String, Any?> = 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<String, Any?> {
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 * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the * [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the

View File

@@ -84,6 +84,9 @@ internal object EventDetailProjection {
CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS, CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.EVENT_COLOR_KEY, 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 const val IDX_EVENT_ID = 0
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
const val IDX_EVENT_TIMEZONE = 15 const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16 const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17 const val IDX_EVENT_COLOR_KEY = 17
const val IDX_DURATION = 18
} }
/** /**

View File

@@ -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<EventColorOption>.curatedForPicker(): List<EventColorOption> {
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<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
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<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
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, 0360, 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)))
}
}
}

View File

@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen 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.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen
@@ -172,6 +173,9 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient. // picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) } var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) } var importForm by remember { mutableStateOf<EventForm?>(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 // 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 // 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 // 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. // reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) { LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) { if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert
importForm = requestedInsertForm importForm = requestedInsertForm
onInsertConsumed() onInsertConsumed()
} }
@@ -435,6 +440,7 @@ fun CalendarHost(
onClose = { importUri = null }, onClose = { importUri = null },
onOpenSingle = { form -> onOpenSingle = { form ->
importUri = null importUri = null
importFormSource = ImportSource.File
importForm = form importForm = form
}, },
) )
@@ -443,6 +449,7 @@ fun CalendarHost(
EventEditScreen( EventEditScreen(
initialDateIso = null, initialDateIso = null,
initialForm = form, initialForm = form,
initialFormSource = importFormSource,
onClose = { importForm = null }, onClose = { importForm = null },
onSaved = { importForm = null }, onSaved = { importForm = null },
) )

View File

@@ -141,13 +141,20 @@ class EventDetailViewModel @Inject constructor(
private suspend fun loadDetail(target: Target): EventDetailUiState = try { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId) val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this // The Events row holds the series start; replace it with this
// occurrence's time so recurring events render correctly. // occurrence's time so recurring events render correctly. An external
val corrected = detail.copy( // "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
instance = detail.instance.copy( // bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
start = Instant.fromEpochMilliseconds(target.beginMillis), // own DTSTART/DTEND instead of overriding it to the epoch.
end = Instant.fromEpochMilliseconds(target.endMillis), 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() val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId } .firstOrNull { it.id == corrected.instance.calendarId }
EventDetailUiState.Success( EventDetailUiState.Success(
@@ -168,6 +175,16 @@ class EventDetailViewModel @Inject constructor(
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */ /** 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) 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/<id>` 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). */ /** A filesystem-safe `.ics` file name from an event title (or a fallback). */

View File

@@ -89,6 +89,7 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -182,12 +183,14 @@ fun EventEditScreen(
editKey: LongArray? = null, editKey: LongArray? = null,
initialStartMinutes: Int? = null, initialStartMinutes: Int? = null,
initialForm: EventForm? = null, initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
viewModel: EventEditViewModel = hiltViewModel(), viewModel: EventEditViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(initialDateIso, editKey, initialForm) { LaunchedEffect(initialDateIso, editKey, initialForm) {
when { when {
// Single-event .ics open: the form arrives prefilled for review. // A prefilled open: a single-event .ics for review, or an external
initialForm != null -> viewModel.openImported(initialForm) // ACTION_INSERT intent. The source drives how reminders are seeded.
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
editKey != null -> viewModel.openForEdit( editKey != null -> viewModel.openForEdit(
eventId = editKey[0], eventId = editKey[0],
beginMillis = editKey[1], beginMillis = editKey[1],
@@ -202,6 +205,7 @@ fun EventEditScreen(
} }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val loadFailed by viewModel.loadFailed.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 form is intentionally forgotten on every close (cancel or save) so
// the next open starts clean; it survives rotation because openNew / // 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), modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
) { ) {
if (palette.isNotEmpty()) { 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( ColorSwatchRow(
colors = palette.map { it.argb }, colors = swatches,
selected = selected, selected = selected,
onSelect = { argb -> onSelect = { argb ->
palette.firstOrNull { it.argb == argb } val option = palette.firstOrNull { it.argb == argb }
?.let { onPickKey(it.key, it.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, dark = dark,
) )

View File

@@ -50,6 +50,36 @@ import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject 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<Int>,
)
/** /**
* Holds the event form being composed. The form's calendar id resolves to * 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 * (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 // freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form. // their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false) 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<ImportReminderPrompt?>(null)
/** True when the event to edit couldn't be loaded; the screen closes itself. */ /** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow() val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
/** /**
* The event being edited plus everything the form saw at load time. * The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the * For recurring events the write scope is chosen at save time; the
@@ -112,7 +148,17 @@ class EventEditViewModel @Inject constructor(
val allDay: List<Int>, val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>, val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>, val allDayOverrides: Map<Long, List<Int>>,
) ) {
/** The default reminders for an event on [calendarId] of the given kind. */
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
timedGlobal = timed,
allDayGlobal = allDay,
timedOverrides = timedOverrides,
allDayOverrides = allDayOverrides,
calendarId = calendarId,
isAllDay = isAllDay,
)
}
private data class ExternalInputs( private data class ExternalInputs(
val writable: List<CalendarSource>, val writable: List<CalendarSource>,
@@ -241,18 +287,39 @@ class EventEditViewModel @Inject constructor(
} }
/** /**
* Seed a fresh event from a parsed `.ics` file (the single-event "open into * Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
* the create form" path). [form] already carries the file's fields; its * file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
* [EventForm.calendarId] is null so the calendar still resolves to the * app/widget creating an event, e.g. Google Maps' "add to calendar";
* last-used/first-writable one, and reminders are frozen as touched so the * [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
* settings default never overwrites what the file specified. No-op when a * calendar still resolves to the last-used/first-writable one.
* form is already open, so the prefill survives configuration changes. *
* 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 if (_form.value != null || _editTarget.value != null) return
_remindersTouched.value = true
_revealed.value = form.populatedFields() _revealed.value = form.populatedFields()
_form.value = form _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) { private fun applyDefaultReminder(calendarId: Long? = null) {
if (_editTarget.value != null || _remindersTouched.value) return if (_editTarget.value != null || _remindersTouched.value) return
viewModelScope.launch { viewModelScope.launch {
val defaults = combine( val defaults = reminderDefaults()
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
val targetId = calendarId ?: resolvedCalendarId.first() val targetId = calendarId ?: resolvedCalendarId.first()
// Re-check after suspending: bail if the form closed or the user edited. // Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = resolveDefaultReminder( val reminders = defaults.resolveFor(targetId, form.isAllDay)
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
allDayOverrides = defaults.allDayOverrides,
calendarId = targetId,
isAllDay = form.isAllDay,
)
_form.value = form.copy(reminders = reminders) _form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and // Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field. // 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 * 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 * 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 _editTarget.value = null
_loadFailed.value = false _loadFailed.value = false
_remindersTouched.value = false _remindersTouched.value = false
_importReminderPrompt.value = null
} }
/** Unfold one optional field, picked in the "more fields" dialog. */ /** Unfold one optional field, picked in the "more fields" dialog. */

View File

@@ -117,6 +117,16 @@
<string name="event_edit_gone_title">Event deleted</string> <string name="event_edit_gone_title">Event deleted</string>
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string> <string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">This event was imported with %1$d reminder.</item>
<item quantity="other">This event was imported with %1$d reminders.</item>
</plurals>
<string name="import_reminder_prompt_apply">Apply default</string>
<string name="import_reminder_prompt_keep">Keep as-is</string>
<!-- Event form — recurrence picker (v1.3) --> <!-- Event form — recurrence picker (v1.3) -->
<string name="event_edit_recurrence_none">Does not repeat</string> <string name="event_edit_recurrence_none">Does not repeat</string>
<string name="event_edit_recurrence_custom">Custom</string> <string name="event_edit_recurrence_custom">Custom</string>

View File

@@ -32,6 +32,7 @@ class EventDetailMapperTest {
accessLevel: Any? = null, accessLevel: Any? = null,
timezone: String? = null, timezone: String? = null,
selfStatus: Any? = null, selfStatus: Any? = null,
duration: String? = null,
): MapColumnReader = MapColumnReader( ): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId, EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title, EventDetailProjection.IDX_TITLE to title,
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone, EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus, EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey, EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
EventDetailProjection.IDX_DURATION to duration,
) )
private fun attendeeReader( private fun attendeeReader(
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() { fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
// A yearly birthday/anniversary anchored before the epoch has a // A yearly birthday/anniversary anchored before the epoch has a
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry // 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 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).isNotNull()
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin) 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/<id> 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:0010: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 @Test

View File

@@ -220,6 +220,117 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) 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 --- // --- per-event colour ---
@Test @Test

View File

@@ -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<EventColorOption>().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<EventColorOption> = 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,
)
}

View File

@@ -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]).