The create form (v1.2) now edits: a pencil on the detail screen (writable calendars only, contextual WRITE upgrade like delete) opens it prefilled via EventDetail.toEditForm; populated sections always show, the calendar is fixed, and a dirty-check writes only changed columns (pristine saves are no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope and asks how far the change reaches (Google model): "only this event" = modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as explicit NULLs since the provider clones the parent row), "this and all following" = series split (insert new event first, then truncate), "all events" = series-row update with the time delta applied to the series DTSTART. A changed rule drops the exception option. Delete gained the same middle scope. Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT + weekly BYDAY with locale-ordered weekday toggles) behind a picker on create and edit; unrepresentable rules render humanized (shared ui/common RecurrenceText) and survive verbatim. UNTIL validation flags rules ending before the event starts. Provider lessons baked in (verified on-device via adb probes): instance caches regenerate only from an update's own values, so truncation sends the full time-column set (truncateSeries) — RRULE-only updates left a stale duplicate occurrence on the split day; UNTIL is written as the local end of day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones can't leak an extra day. Reminder edits reconcile against actual provider rows, keeping untouched rows' methods. Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception mapper paths, repository pass-throughs, prefill + populatedFields, raw-title mapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 KiB
Calendula - Plan 03: Write Support (Milestone 2 / v2.0)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Calendula kann Events anlegen, bearbeiten und löschen — direkt über
CalendarContract-Writes, ohne eigene DB. Der V1-Spec dient als Leitplanke,
nicht als Gesetz: Ausgeliefert wird in vier Slices (v1.1 → v2.0), jeder Slice
ist für sich releasebar und lässt ./gradlew lint test assembleDebug grün.
Architecture: Writes laufen durch dieselbe Schichtung wie Reads:
ui/ → CalendarRepository (Interface) → CalendarDataSource →
ContentResolver.insert/update/delete. Kein neuer Layer, keine Transaktions-
Abstraktion — der Provider notified nach jedem Write selbst, der bestehende
ContentObserver-Tick aktualisiert alle Views automatisch (F3 gilt unverändert).
Domain bleibt pure Kotlin.
Leitentscheidungen (Abweichungen / Präzisierungen ggü. Spec §2 "V2"):
- Permission-Strategie:
WRITE_CALENDARkommt ins Manifest. Das Onboarding fragt READ+WRITE zusammen an (eine System-Dialog-Gruppe), zwingend bleibt nur READ — wer Write ablehnt, nutzt die App weiter read-only. v1.0-Upgrader (haben nur READ) bekommen den WRITE-Request kontextuell beim ersten Schreib-Versuch. Onboarding-Footnote verliert die "Nur Lesezugriff"- Behauptung (wäre mit Manifest-Eintrag gelogen). - Read-only-Kalender respektieren:
Calendars.CALENDAR_ACCESS_LEVELwird mitgelesen (canModifyContents= Level ≥CAL_ACCESS_CONTRIBUTOR). Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions, Geburtstags- und andere read-only-Kalender. - Recurring Events: Löschen bietet "Nur dieser Termin" (Exception-Insert
via
Events.CONTENT_EXCEPTION_URImitSTATUS_CANCELED+ORIGINAL_INSTANCE_TIME) vs. "Ganze Serie" (Delete der Events-Row). Bearbeiten startet mit "ganze Serie"; Occurrence-Edit (Exception mit neuen Werten) folgt erst, wenn das Serien-Edit stabil ist. - Kein RRULE-Editor in v1.2: Create startet ohne Wiederholungs-UI (einmalige Events). Ein einfacher Recurrence-Picker (täglich/wöchentlich/ monatlich/jährlich + Ende) kommt mit v1.3/v2.0.
- Conflict UX (Spec V2 "event modified externally during edit"): kein Locking. Beim Speichern wird gegen die beim Laden gemerkte Row verglichen (Dirty-Check auf den editierten Feldern); bei externem Konflikt Dialog "Überschreiben / Verwerfen". Mehr ist YAGNI.
Slices
| Slice | Inhalt | Status |
|---|---|---|
| v1.1 | Write-Fundament: WRITE_CALENDAR, canModifyContents, Delete (Serie + einzelnes Vorkommen) |
ausgeliefert (v1.1.0, 2026-06-11) |
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | implementiert (Release wartet auf On-Device-Review) |
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
v1.1 — Write-Fundament + Delete
Build/Manifest:
AndroidManifest.xml:WRITE_CALENDARergänzen
Data layer:
Projections.kt:CALENDAR_ACCESS_LEVELinCalendarProjectionModels.kt:CalendarSource.canModifyContents: Boolean(Defaultfalse). Kein neuerFailureReason— Delete-Fehler sind ein Snackbar-Fall, kein Full-Screen-FailureCalendarMapper.kt: Access-Level →canModifyContentsCalendarDataSource:deleteEvent(eventId),deleteOccurrence(eventId, beginMillis)— Impl inAndroidCalendarDataSource(deleteauf Events-URI bzw. Exception-Insert),WriteFailedExceptionbei 0 rows / null-UriCalendarRepository(+Impl): beide Methoden durchreichen, aufio
UI:
EventDetailUiState.Success.canModify(Kalender-Lookup im ViewModel)EventDetailViewModel:delete(mode)mit eigenem One-Shot-State (Idle/Deleting/Deleted/Failed);SecurityException→ kontextueller WRITE-Request statt Failure-ScreenEventDetailScreen: Edit/Delete nur wenncanModify; Delete → Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"), Erfolg → zurück, Fehler → Snackbar- Onboarding (
PermissionScreen):RequestMultiplePermissionsREAD+WRITE, Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
Tests:
FakeCalendarDataSource: Write-Ops aufnehmenCalendarRepositoryImplTest: delete-Pfade (Erfolg, Fehler)CalendarMapperTest: Access-Level-Mapping
v1.2 — Create
EventForm-Domain-Modell + Validierung (problems(): EndBeforeStart, NoCalendar; leerer Titel und Instant-Events erlaubt)EventEditScreen(ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker- FAB-Stack auf allen drei Hauptansichten (
CalendarFabColumn: "+" immer, Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag - Kalender-Vorauswahl: explizit > zuletzt benutzt
(
CalendarPrefs.lastUsedCalendarIdstatt Settings-Eintrag) > erster beschreibbarer; Picker bietet nur beschreibbare Kalender an insertEvent(form): Longim DataSource;EventWriteMapper(JVM-testbar) normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
v1.3 — Edit
Domain:
EventForm.rrule(roher RRULE-Wert, null = einmalig); komplexe Regeln (ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten, solange der Picker sie nicht ersetztSimpleRecurrence(FREQ + INTERVAL + UNTIL/COUNT + wöchentliches BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mitparseSimpleRecurrence/toRRule(Recurrence.kt, JVM-getestet)EventDetail.toEditForm(begin, end, zone)— Prefill inkl. all-day- Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)- Validierung:
RecurrenceEndsBeforeStart(UNTIL vor erstem Tag hieße null Vorkommen — Event würde unsichtbar)
Data layer:
buildEventUpdateValues(original, updated, seriesDtStart, zone)— Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit: einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend → Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTENDCalendarDataSource.updateEvent(eventId, original, updated)— Events-Row- Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)insertEventversteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)CalendarRepository(+Impl).updateEventdurchgereicht, aufioEventDetailMapper: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr — der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
UI:
EventEditViewModel.openForEdit(lädt Detail, merkt Original für Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit Werten werden unabhängig vom Settings-Default eingeblendetEventEditScreen:editKey-Parameter, Kalender im Edit-Modus fixiert, Repeat-Karte +RecurrencePickerDialog(Presets per Tap, Custom-Schritt mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl, OptionCard-Stil)- Recurrence-Humanizer nach
ui/common/RecurrenceText.kt(Detail + Formular) EventDetailScreen: Edit-Action (nurcanModify, kontextueller WRITE-Request wie Delete); Save schließt Formular und Detail (die getappte Occurrence existiert danach evtl. nicht mehr)- Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback): Die
Scope-Frage kommt beim Speichern (Google-Modell, Review-Feedback):
ein dirty wiederkehrender Termin parkt in
SaveUiState.AwaitingScope, der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden / Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser" (eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt eine Modified-Occurrence-Exception (CONTENT_EXCEPTION_URI, alle Formularwerte, leere Optionals als explizite NULLs weil der Provider die Serien-Row klont), Reminder werden gegen die tatsächlichen Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split: neues Event mit den Formularwerten (insert zuerst — schlägt es fehl, bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt; ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP) - Delete dreistufig (Review-Feedback): "Nur dieser Termin" /
"Dieser und alle folgenden" (RRULE-Truncation via
rruleTruncatedAt) / "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie löschen - Split-Duplikat-Bugfix (On-Device-Review): Nach dem Serien-Split
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
adb-Probe verifiziert): der Provider regeneriert die Instances eines
Events nur aus den Values des Updates selbst — ein RRULE-only-
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
EVENT_TIMEZONE) zusammen (
truncateSeries), wie AOSPs EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff = Ende des Vortags in der Event-Zeitzone (previousLocalDayEndUtcMillis) statt Occurrence−1s, und der Recurrence-Picker rendert UNTIL als lokales Tagesende in UTC (toRRule(zone)) statt pauschalT235959Z(sonst kann bei UTC+x ein Extra-Tag hineinrutschen) CalendarHost: Edit-Overlay mit Held-Key-PatternEventFormField.Recurrence(Formular, "Mehr Felder", Settings-Default)- Strings DE+EN
Tests:
RecurrenceTest(Parse/Render/Roundtrip, Ablehnung komplexer Regeln)EventFormTest: Prefill (timed/all-day),populatedFields, UNTIL-ValidierungEventWriteMapperTest: Duration-Format, Dirty-Check-Pfade (Text-only, Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)CalendarRepositoryImplTest+FakeCalendarDataSource: update-PfadeEventDetailMapperTest: roher Titel
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
v2.0 — Abschluss (Skizze)
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
- Occurrence-Edit (Exception mit geänderten Werten)
- Konflikt-Dialog beim Speichern
- Changelog, F-Droid-Metadaten, Release-Tag