Compare commits

...

1 Commits

Author SHA1 Message Date
bb7954d026 Remove superpowers planning docs; ignore CLAUDE.md
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:43:22 +02:00
8 changed files with 3 additions and 6246 deletions

3
.gitignore vendored
View File

@@ -55,3 +55,6 @@ Thumbs.db
# KSP
.ksp/
# Claude Code
/CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +0,0 @@
# 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"):**
1. **Permission-Strategie:** `WRITE_CALENDAR` kommt 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).
2. **Read-only-Kalender respektieren:** `Calendars.CALENDAR_ACCESS_LEVEL` wird
mitgelesen (`canModifyContents` = Level ≥ `CAL_ACCESS_CONTRIBUTOR`).
Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions,
Geburtstags- und andere read-only-Kalender.
3. **Recurring Events:** Löschen bietet "Nur dieser Termin" (Exception-Insert
via `Events.CONTENT_EXCEPTION_URI` mit `STATUS_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.
4. **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.
5. **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 | ausgeliefert (v1.3.0, 2026-06-11) |
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
## v1.1 — Write-Fundament + Delete
**Build/Manifest:**
- [x] `AndroidManifest.xml`: `WRITE_CALENDAR` ergänzen
**Data layer:**
- [x] `Projections.kt`: `CALENDAR_ACCESS_LEVEL` in `CalendarProjection`
- [x] `Models.kt`: `CalendarSource.canModifyContents: Boolean` (Default `false`).
Kein neuer `FailureReason` — Delete-Fehler sind ein Snackbar-Fall, kein
Full-Screen-Failure
- [x] `CalendarMapper.kt`: Access-Level → `canModifyContents`
- [x] `CalendarDataSource`: `deleteEvent(eventId)`, `deleteOccurrence(eventId, beginMillis)`
— Impl in `AndroidCalendarDataSource` (`delete` auf Events-URI bzw.
Exception-Insert), `WriteFailedException` bei 0 rows / null-Uri
- [x] `CalendarRepository(+Impl)`: beide Methoden durchreichen, auf `io`
**UI:**
- [x] `EventDetailUiState.Success.canModify` (Kalender-Lookup im ViewModel)
- [x] `EventDetailViewModel`: `delete(mode)` mit eigenem One-Shot-State
(Idle/Deleting/Deleted/Failed); `SecurityException` → kontextueller
WRITE-Request statt Failure-Screen
- [x] `EventDetailScreen`: Edit/Delete nur wenn `canModify`; Delete →
Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"),
Erfolg → zurück, Fehler → Snackbar
- [x] Onboarding (`PermissionScreen`): `RequestMultiplePermissions` READ+WRITE,
Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
**Tests:**
- [x] `FakeCalendarDataSource`: Write-Ops aufnehmen
- [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
- [x] `CalendarMapperTest`: Access-Level-Mapping
## v1.2 — Create
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
NoCalendar; leerer Titel und Instant-Events erlaubt)
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
(`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster
beschreibbarer; Picker bietet nur beschreibbare Kalender an
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
## v1.3 — Edit
**Domain:**
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
solange der Picker sie nicht ersetzt
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
null Vorkommen — Event würde unsichtbar)
**Data layer:**
- [x] `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 DTEND
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
**UI:**
- [x] `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 eingeblendet
- [x] `EventEditScreen`: `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)
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
getappte Occurrence existiert danach evtl. nicht mehr)
- [x] **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)
- [x] **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
- [x] **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 Occurrence1s, und der Recurrence-Picker rendert UNTIL als
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
- [x] Strings DE+EN
**Tests:**
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
- [x] `EventDetailMapperTest`: 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 (Scope-Recut 2026-06-11, nach v1.4)
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
Kalender, optionale Felder versteckt); der Sheet spart nur einen
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
Praxis-Feedback wieder aufnehmen
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
ausgeliefert (vorgezogen)
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
schreibt weiterhin nur dirty Felder
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
aktualisiert (DE+EN)
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
damit abgeschlossen)

View File

@@ -1,119 +0,0 @@
# Calendula - Plan 04: Reminder Notifications (v1.4)
> **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 stellt Erinnerungen selbst als Notification zu (Etar-Modell).
Der Provider plant die Alarme und broadcastet
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
`METHOD_ALERT`.
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
"fällig & noch SCHEDULED".
3. Etar postet aus dem Zustand `SCHEDULED FIRED` und verwaltet Dismiss über
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
`setOnlyAlertOnce`).
**Leitentscheidungen:**
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
v1.0v1.3-Upgrader, die das Feature so entdecken.
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
wenn sie fehlt.
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
der Detail-Overlay selbst).
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
Vollbild-Alarm.
---
## Tasks
**Manifest / Resourcen:**
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
host=com.android.calendar`; `MainActivity``launchMode="singleTop"`
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
**Prefs:**
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
**Data layer (`data/reminders/`):**
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
title, location, isAllDay
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
`STATE_SCHEDULED AND ALARM_TIME <= now`;
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
`SecurityException` → Log (Write-Recht optional)
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
Category EVENT), Content-PendingIntent auf `MainActivity` mit
eventId/begin/end
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
**Receiver:**
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
Notifications systemseitig geblockt; sonst posten → `markFired`
**UI:**
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
setzen `reminder_onboarding_done`
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
onNewIntent) in Compose-State
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
Permission-Request beim Einschalten) + Duplikat-Hinweistext
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
On-Device-Review

View File

@@ -1,150 +0,0 @@
# Calendula - Plan 05: ICS Export (v2.7, Branch 1 von 2)
> **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:** Die Schreib-Hälfte des `.ics`-Themas. Calendula serialisiert eigene
Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als
Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender
(`ACCOUNT_TYPE_LOCAL`) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes
Gerät sonst Totalverlust. Diese Branch baut **nur Export**; Import
(Parser, Restore, Open-into-form) folgt in Branch 2 (`feat/ics-import`),
beide landen zusammen in **einem** Release v2.7.0 — es gibt also keine
Zwischenversion, die UIDs schreibt, ohne sie je zu lesen.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review (gemeinsam mit Branch 2).
**Architecture:** Neue reine-Kotlin-Engine `domain/ics/` — kein
`CalendarContract`, keine Android-Deps, voll JVM-testbar. Kern ist
`IcsWriter` (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten,
gibt einen `VCALENDAR`-String zurück). Keine ICS-Library: wir bleiben auf
`kotlinx-datetime` (kein `java.time`-Desugaring) und hand-rollen wie schon
bei RRULE in `Recurrence.kt`. Das Schreiben in eine SAF-Datei / das
Share-Intent liegt in einer dünnen Android-Schicht
(`data/ics/IcsExporter` o. ä.), die Provider-Reads → Domain-Modelle →
`IcsWriter``OutputStream` verdrahtet.
**Recherche-Befunde (Codebase, 2026-06-18):**
1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist
`kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in
`domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen
`UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und
nutzt `SimpleRecurrence.toRRule()` direkt.
2. **Kein UID-Handling.** `Events.UID_2445` wird heute nirgends gelesen oder
geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID
matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt
**diese** Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch
unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release).
3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige:
`EVENT_TIMEZONE` landet in `EventDetail.eventTimezone`, das Detail zeigt ein
Fremdzonen-Label nur bei Abweichung vom Gerät (`foreignTimeZoneLabel`).
Schreiben (`EventWriteMapper.toWriteTimes`): all-day → UTC-Mitternachten,
`EVENT_TIMEZONE="UTC"`; getimt → `EVENT_TIMEZONE = zone.id`, und alle Caller
übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst
erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte
Events behalten ihre Originalzone.
**Leitentscheidungen:**
1. **Zeitzonen-Regel beim Schreiben (fallbasiert):**
- **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv
(Tag-danach). Keine Zone — trivial korrekt.
- **Getimt, nicht wiederkehrend** → UTC-Instant `…T…Z`. Ein Instant ist ein
Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei.
- **Getimt, wiederkehrend** → `DTSTART;TZID=<EVENT_TIMEZONE>:<lokale Wandzeit>`.
Eine Serie muss an der **Wandzeit** verankert sein, sonst driftet ein
„wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone
liegt bereits in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine
`kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone).
- **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen
Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf
(`kotlinx-datetime`/`java.time` kennen jede IANA-Id). Technisch nicht
RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als
bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports),
kein Blocker. Voll-`VTIMEZONE` ist „später, falls nötig".
2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445`
(z. B. `<random-uuid>@calendula`). Bestehende Events ohne UID exportieren
wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
`event-id + DTSTART` (`<id>-<dtstart>@calendula`), damit derselbe Bestand
über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht
verdoppelt. Bestehende Rows werden **nicht** rückwirkend gestempelt
(kein Migrations-Sweep über fremde Kalender).
3. **Manueller Export, kein Background.** Backup via
`ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name
`calendula-backup-<datum>.ics`); Einzel-Event-Share via `ACTION_SEND` mit
einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein
geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos;
Auto-Backup bleibt explizit Roadmap-`later`).
4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle
gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die
Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. mit,
damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren
kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien.
*Offen, vor dem Backup-Task zu fixieren:* exaktes Property fürs
Kalender-Mapping (`X-WR-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen;
für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property
wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`).
5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau
die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1),
`LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus den
Remindern (DISPLAY, `TRIGGER` = `-PT<min>M`), `STATUS`
(CONFIRMED/TENTATIVE/CANCELLED), `TRANSP` (Free→TRANSPARENT/Busy→OPAQUE),
`UID`, `DTSTAMP`. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID-
Ausnahmen) bleiben **vorerst weg** — Export erzeugt nichts, was Import in
Branch 2 nicht auch wieder lesen kann.
6. **Korrekte RFC-5545-Mechanik:** Zeilen-Folding bei >75 Oktett (CRLF +
Space-Fortsetzung), Text-Escaping (`\` `;` `,` `\n`), CRLF-Zeilenenden,
`PRODID`/`VERSION:2.0`-Header. Eine reine, einzeln getestete Hilfsschicht
(`IcsLine`/`fold`/`escapeText`), nicht ad hoc im Writer verstreut.
---
## Tasks
**Domain-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test
(`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`,
LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`.
- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant
+ isAllDay + zoneId, recurrenceRule?, location, description,
reminderMinutes, status, availability, uid, calendarName) — entkoppelt
vom Provider-Modell
- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event
`VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test
`IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone,
Reminder, Escaping)
- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "<eventId>-<dtstartMillis>@calendula"`)
+ Stabilitätstest
**Provider → Domain (`data/calendar/IcsExportMapper.kt`):**
- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl.
DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`),
`EventExportProjection`; Datasource-Methode `exportableEvents()` +
Repository `exportEvents()`; Test `IcsExportMapperTest`
- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem
Create
**Android-Export-Schicht:**
- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile`
(FileProvider-Cache) als UTF-8
- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein
Event (one-off) → Cache-File über `FileProvider``ACTION_SEND`
- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
`ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar
(Plural „Exported N events")
- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares)
- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/
Fehler + Plural, dateierter Default-Name
**Abschluss:**
- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)**
- [x] CHANGELOG (`[Unreleased]`) ergänzt
- [ ] On-Device-Review; **kein** Tag/Release vor Review und vor Merge von
Branch 2 (`feat/ics-import`)
**Offene Detail-Calls (vor Review klären, nicht-blockierend):**
- Kalender→Event-Mapping nutzt das per-`VEVENT`-Property `X-CALENDULA-CALENDAR`
(statt `X-WR-CALNAME`), damit eine kombinierte Datei mehrere Kalender trägt.
- Backup = **eine** kombinierte `VCALENDAR`-Datei über alle lokalen Kalender.
- EXDATE / `RECURRENCE-ID`-Ausnahmen werden beim Export ausgelassen
(`ORIGINAL_ID IS NULL`) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.

View File

@@ -1,122 +0,0 @@
# Calendula - Plan 06: ICS Import (v2.7, Branch 2 von 2)
> **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:** Die Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
Kalender. Beide Branches landen in **einem** Release v2.7.0.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter`
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
Repository-Methode `importEvents(targetCalendarId, events)``ImportSummary`
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
Event-Anzahl.
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
den Rest der Datei durch.
**Leitentscheidungen:**
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
- `…T…Z` → UTC-Instant.
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID`
Gerätezone als Fallback (+ Warnung).
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
seine `TZID` nicht in der OS-DB ist).
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null`
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
Datei → freundlicher „nichts gefunden"-Hinweis.
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
verlustfrei. Events ohne UID bekommen beim Insert eine frische
(`UUID@calendula`, wie `insertEvent`).
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
Notification-Deep-Link).
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP``EventStatus`/`Availability`.
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
vorhanden).
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
---
## Tasks
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
gequotete Params)
- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
- [x] `IcsParser.parse(text)``IcsParseResult(events, warnings)`: VCALENDAR/
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
**Datenschicht (`data/calendar/` + `data/ics/`):**
- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
- [x] Repository `importEvents(targetCalendarId, events)``ImportSummary`
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
Fake-Datasource
- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
**Intent + Routing:**
- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
>1 → Bulk-Import-Screen; 0 → Hinweis
**UI:**
- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
leere-Datei-Hinweis
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
Branches gemerged sind und On-Device-Review durch ist

View File

@@ -1,406 +0,0 @@
# Calendula - V1 Design Spec
**Date:** 2026-06-08
**Status:** Draft for review
**Author:** Jean-Luc Makiola (with Claude)
## 1. Motivation & Goal
Eine Android-native, Open-Source-Kalender-App im Material 3 Expressive Design.
Schließt die Lücke, dass es derzeit keinen optisch zeitgemäßen Kalender abseits
von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
**Was die App NICHT macht:**
- Eigene CalDAV/iCal-Synchronisation - das übernimmt der Android `CalendarContract`
bzw. Drittsoftware wie DAVx5
- Eigene lokale Event-DB - alle Daten leben im `CalendarContract`
**Was die App macht:**
- Schöne, moderne UI über Android-Bordmittel
- Liest aus `CalendarContract` (alle Quellen: Nextcloud/CalDAV via DAVx5,
Google, lokal, WebCal-Subscriptions)
- V1 ist read-only; Schreibrechte kommen in V2
## 2. Scope - V1 MVP (Variante "B")
### In-Scope
- 3 Hauptansichten: Monat, Woche, Tag
- Event-Detail-Sheet (read-only Detailansicht)
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
- Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
- Permission-Flow für `READ_CALENDAR`
- Empty-States und Error-Recovery
- DE + EN Lokalisierung
- Tests + CI ab Tag 1
### Out-of-Scope (V2+)
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
- Event-Create/Edit/Delete (V2)
- Home-Screen-Widget
- Volltextsuche
- Quick-Add
- Notifications/Reminders (System macht das schon, nicht doppeln)
- Tablet-/Foldable-spezifische Layouts
- iOS (Kotlin-Native ist explizit Android-only)
## 3. Tech Stack
| Layer | Wahl | Begründung |
|---|---|---|
| Sprache | Kotlin 2.0+ | Android-Native-Standard |
| UI | Jetpack Compose + Material3 Expressive (1.5+) | Echter M3 Expressive Support |
| Min SDK | 29 (Android 10) | Modern, keine Compat-Pfade |
| Target SDK | 36 (Android 16) | Aktuell, wie HouseHoldKeaper CI |
| DI | Hilt | Industriestandard |
| Persistenz Prefs | DataStore (Preferences) | Theme, Wochenstart, Filter-State |
| Persistenz Daten | keine | Source of Truth bleibt `CalendarContract` |
| Datum/Zeit | `kotlinx.datetime` (Domain), `java.time` an Provider-Grenze | Saubere API |
| Navigation | Compose Navigation, Single-Activity | Standard |
| Lokalisierung | Android Resources (`strings.xml`) + Plurals | DE + EN ab V1 |
| Tests | JUnit5, Truth, Turbine, Compose UI Test | JVM-first, Instrumented nur für ContentResolver-Integration |
| Build | Gradle Kotlin DSL + Version Catalog | Lesbar, typsicher |
| CI | Gitea Workflows (adaptiert von HouseHoldKeaper) | Gleiche Konvention wie restliche Projekte |
### Permissions
- `READ_CALENDAR` (einzige Runtime-Permission)
- `android.permission.QUERY_ALL_PACKAGES` **nicht** nötig (Maps-Intent geht ohne)
### App-Identifier
- **App-Name:** Calendula (vom lateinischen *kalendae* - "der erste Tag des Monats", Wortwurzel von "Kalender"; gleichzeitig der Name der Ringelblume)
- **Package:** `de.jeanlucmakiola.calendula`
- Convention identisch zu `HouseHoldKeaper`: `de.jeanlucmakiola.<app_name>`
## 4. Architektur
### Modul-Struktur
Single Gradle module `:app` für V1. Feature-Split (`:core`, `:feature-*`) erst
wenn nötig - YAGNI.
### Package-Layout
```
de.jeanlucmakiola.calendula/
├── CalendulaApp.kt + MainActivity.kt
├── data/ ContentResolver-Wrapper, Repositories
├── domain/ Pure-Kotlin Models (Event, CalendarSource)
└── ui/
├── theme/ M3 Expressive Theme, Dynamic Color
├── month/ Monatsansicht (Composable + ViewModel + State)
├── week/ Wochenansicht
├── day/ Tagesansicht
├── detail/ Event-Detail-Sheet
├── filter/ Kalender-Filter-Sheet
├── settings/ Settings-Screen
├── permission/ Permission-Request-Screen
└── common/ Geteilte Composables (LoadingScreen-Helper, FailureScreen-Helper)
```
### Layer-Verantwortlichkeiten
- **data/**: Nur Layer der Android-Klassen kennt (`ContentResolver`, `Cursor`,
`CalendarContract`). Mapped auf Domain-Modelle.
- **domain/**: Pure Kotlin. Keine Android-Imports.
- **ui/**: Compose-Code, ViewModels. Hängt von domain ab, niemals direkt an data.
## 5. Datenfluss & Domain-Modell
### Domain-Modelle (pure Kotlin)
```kotlin
data class CalendarSource(
val id: Long,
val displayName: String,
val accountName: String, // z.B. "jlmak@nextcloud.example.com"
val accountType: String, // z.B. "at.bitfire.davdroid" (DAVx5), "com.google" (Google), "LOCAL"
val color: Int,
val isVisibleInSystem: Boolean, // CalendarContract.Calendars.VISIBLE
)
data class EventInstance(
val instanceId: Long, // Instances._ID (eindeutig pro Vorkommen)
val eventId: Long, // Events._ID (gleich für alle Vorkommen)
val calendarId: Long,
val title: String,
val start: Instant,
val end: Instant,
val isAllDay: Boolean,
val color: Int, // Effektiv: Event.color ?: Calendar.color
val location: String?,
)
data class EventDetail(
val instance: EventInstance,
val description: String?,
val organizer: String?,
val attendees: List<Attendee>,
val rrule: String?, // Read-only: nur "wiederkehrt wöchentlich" anzeigen
)
data class Attendee(val name: String, val email: String?, val status: AttendeeStatus)
enum class AttendeeStatus { Accepted, Declined, Tentative, NeedsAction, Unknown }
```
### Repository
```kotlin
interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
}
```
**Implementation-Details:**
- Hält `ContentObserver` auf `CalendarContract.CONTENT_URI` registriert
- Bei Observer-Trigger: re-query, emit neuer Wert via SharedFlow
- Queries laufen auf `Dispatchers.IO`
- `instances(range)` nutzt `CalendarContract.Instances.CONTENT_BY_DAY_URI`
oder `CONTENT_URI` mit Time-Range - Recurrence-Expansion macht der Provider
- App-eigene "ausgeblendete Kalender-IDs" leben in DataStore als `Set<Long>`,
kombinieren via `combine()` mit Calendar-Flow
### ViewModel-Pattern
Ein ViewModel pro Top-Level-Screen. State immer als sealed interface
(siehe Section 7 - Loading/Failure/Success).
ViewModel kennt aktuelle "Cursor"-Position (welcher Monat/Woche/Tag) und
fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
## 6. Screens & Menüs
### Hauptscreens
**S1 - Monatsansicht**
- Zeigt einen Monat im Überblick
- Pro Tag erkennbar: hat-Events / keine-Events, ggf. Andeutung über Anzahl/Farbe
- Navigation: vorwärts/zurück zwischen Monaten
- Tap auf Tag → Tagesansicht für diesen Tag
- Heute deutlich markiert
**S2 - Wochenansicht**
- Zeigt eine Woche mit Zeitschiene
- Events auf ihrer Uhrzeit, Calendar-Farbe
- Overlap-Events: nebeneinander aufgelöst
- All-Day-Events extra dargestellt
- Navigation: vorwärts/zurück zwischen Wochen
- Tap Event → Event-Detail-Sheet
**S3 - Tagesansicht**
- Eine Spalte, mehr Detail pro Event als Wochenansicht
- All-Day-Events extra
- Navigation: vorwärts/zurück zwischen Tagen
- Tap Event → Event-Detail-Sheet
**S4 - Event-Detail-Sheet (Bottom-Sheet, ModalBottomSheet)**
- Pflicht-Inhalte: Titel, Start/Ende oder "Ganztägig", Kalender-Zugehörigkeit
- Konditional: Ort (Tap → Maps-Intent), Beschreibung, Teilnehmer, RRULE-Hinweis
- Dismissable via Drag oder Back-Geste
### Menüs
**M1 - View-Switcher**
- Wechsel zwischen Monat / Woche / Tag
- Immer erreichbar von allen Hauptansichten
- State persistent (zuletzt aktive Ansicht)
**M2 - Heute**
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
- Erreichbar von allen Hauptansichten
**M3 - Kalender-Filter (Bottom-Sheet)**
- Sichtbare Kalender ein-/ausblenden
- Gruppiert pro Account (Nextcloud / Local / Google / …)
- Pro Eintrag: Name, Calendar-Farbe
- Persistiert in DataStore
- Erreichbar von allen Hauptansichten
**M4 - Settings**
- Theme: System / Light / Dark
- Dynamic Color: an/aus (auto disabled wenn API < 31)
- Wochenstart: Auto (aus Locale) / Mo / So
- Sprache: Auto / DE / EN
- About: Version, Lizenz, Link zum Quellcode (Gitea)
### Spezial-Flows
**F1 - Erst-Start / Permission-Flow**
- Beim ersten App-Start: `READ_CALENDAR`-Request
- Erklärungs-Text: "Wir lesen nur deinen Gerätekalender - keine Daten verlassen das Gerät"
- Bei Denial: friendlicher Recovery-Screen mit Re-Request-Button + Link zu System-Settings
**F2 - Empty-State (keine Kalender / keine Events)**
- Keine Kalender konfiguriert: Hinweis "Füge in DAVx5 oder System-Settings einen Kalender hinzu" mit Intent-Link zu System-Calendar-Settings
- Kalender da, aber aktuelle Ansicht leer: dezent, kein nerviger Placeholder
**F3 - Reaktion auf externe Änderungen**
- DAVx5/System-Calendar ändert sich → App aktualisiert sich automatisch via ContentObserver
- Kein manueller Pull-to-Refresh
## 7. UI-State-Modell: Loading / Failure / Success
**Pflicht-Pattern für jeden Screen.** Keine Ausnahmen.
### ViewModel-State
```kotlin
sealed interface MonthUiState {
data object Loading : MonthUiState
data class Failure(val reason: FailureReason) : MonthUiState
data class Success(
val month: YearMonth,
val eventsPerDay: Map<LocalDate, List<EventInstance>>,
val visibleCalendars: List<CalendarSource>,
) : MonthUiState
}
enum class FailureReason {
PermissionRevoked, // → Re-Request-Screen
NoCalendarsConfigured, // → Empty-State mit Intent zu System-Settings
ProviderUnavailable, // → Retry-Screen
EventNotFound, // → nur für Event-Detail-Sheet
Unknown, // → Fallback
}
```
### Composable-Dispatch
```kotlin
@Composable
fun MonthScreen(viewModel: MonthViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is MonthUiState.Loading -> MonthLoadingScreen()
is MonthUiState.Failure -> MonthFailureScreen(s.reason, onRetry = viewModel::retry)
is MonthUiState.Success -> MonthSuccessScreen(s, ...)
}
}
```
### Pflicht-Composables pro Screen
| Screen | Loading | Failure-Varianten | Success |
|---|---|---|---|
| Monat | Skelett-Grid (Shimmer) | Permission, NoCalendars, Provider | Grid mit Events |
| Woche | Skelett-Schiene | Permission, NoCalendars, Provider | Schiene mit Events |
| Tag | Skelett-Schiene | Permission, NoCalendars, Provider | Schiene mit Events |
| Event-Detail | kompakter Skelett-Sheet | EventNotFound, Provider | Voll-Detail |
| Kalender-Filter | Skelett-Liste | Provider | Liste |
| Settings | sofort (DataStore ist instant) | - | direkt |
**Regeln:**
- Loading ist ein bewusst gestalteter Screen (Skeleton + Shimmer), kein loser Spinner
- Failure ist ein eigener Screen mit Erklärung + Recovery-Action, kein Toast
- Beim UI-Design später: alle drei Varianten pro Screen skizzieren, nie nur Success
- Tests: pro Screen mindestens `renders_loading`, `renders_failure`, `renders_success`
## 8. Error Handling & Edge Cases
### Philosophie
Calendar-Apps dürfen niemals an leeren/malformen Daten crashen. Defensive
Validierung im Repository, kaputte Instanzen still droppen + via `Log.w` loggen.
### Konkrete Fehlerfälle
| Fall | Verhalten |
|---|---|
| ContentResolver-Query wirft (Permission revoked zur Laufzeit) | State → `Failure(PermissionRevoked)`, UI zeigt Re-Request |
| Calendar `displayName` null | Fallback "(Unbenannter Kalender)" |
| Event `title` null/leer | Fallback "(Ohne Titel)" |
| `dtend < dtstart` | Event droppen, Warn-Log |
| `dtstart` vor Unix-Epoch | Event droppen, Warn-Log |
| Maps-Intent fehlt | SnackBar "Keine Karten-App installiert" |
| DataStore-IO-Fehler | Defaults verwenden, weiter, Warn-Log |
### Edge Cases im UI
- **All-Day-Events über mehrere Tage:** in Wochen-/Tagesansicht über mehrere
Tage gespannter All-Day-Strip
- **Events über Mitternacht:** in Wochen-/Tagesansicht am Folgetag fortsetzen
- **Instant Events (start == end):** Mindesthöhe rendern für Tap-Target
- **Viele Events an einem Tag:** in Monatsansicht "+N more" statt Overflow
- **Timezones:** alle Berechnungen in Geräte-Local-TZ, außer all-day = floating
## 9. i18n & Accessibility
### i18n
- `res/values/strings.xml` = englische Master-Strings
- `res/values-de/strings.xml` = deutsche Übersetzungen
- Alle Strings extrahiert, auch Plurals (`<plurals>` für "1 Event" / "N Events")
- Wochentags-/Monatsnamen via `java.time.format.DateTimeFormatter` mit aktiver
Locale (kein Hardcoding)
- Sprach-Override aus Settings via `AppCompatDelegate.setApplicationLocales`
### Accessibility (V1-Minimum)
- Alle interaktiven Elemente: `contentDescription`, Tap-Target ≥ 48dp
- Event-Items: semantisches Label "Titel, Start-Zeit, Dauer, Kalender X"
- Calendar-Color **immer** mit Label/Form kombiniert (nie nur-Farbe-Information)
- Dynamic-Text-Size respektiert (keine fixen sp-Werte für Text)
- Hoher-Kontrast: M3-Theme reagiert automatisch
- TalkBack-Smoke-Tests im UI-Test-Plan
## 10. Testing
Best Practices, kein Diskussionsbedarf:
- **Unit-Tests:** JUnit5 + Truth + Turbine. Repository, ViewModels, Date/Time-Helpers,
ContentResolver-Wrapper (mit Mock-Cursor)
- **UI-Tests:** Compose UI Test pro Screen, mindestens `renders_loading` /
`renders_failure` / `renders_success` + 1-2 Interaktions-Tests
- **Coverage-Ziel:** pragmatisch ~70% lines, 100% für Repository + Date-Logik.
Kein Coverage-Gate in CI, aber Pflicht-Run
- **Instrumented-Tests:** nur für ContentResolver-Integration (echte
CalendarContract-Queries auf Emulator)
## 11. CI/CD
Adaption der `HouseHoldKeaper`-Pipeline, nur Flutter-Steps durch Gradle ersetzt.
### `.gitea/workflows/ci.yaml` (push + PR)
- Setup Java 17 + Android SDK 36
- `./gradlew lint`
- `./gradlew test`
- `./gradlew assembleDebug`
- Trivy Filesystem-Scan (HIGH/CRITICAL, `continue-on-error` wie HHK)
### `.gitea/workflows/release.yaml` (auf Git-Tags)
- Alles aus `ci.yaml`
- Version aus Git-Tag in `app/build.gradle.kts`:
- `versionName = "${tag#v}"`
- `versionCode = MAJOR*10000 + MINOR*100 + PATCH` (HHK-Konvention)
- Keystore aus Gitea Secrets (`KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`)
- `./gradlew assembleRelease`
- F-Droid-Pipeline 1:1 wie HHK: Hetzner-Sync, `fdroid update -c`, Re-Upload
### Repo-Konventionen
- `CHANGELOG.md` wird beim Taggen gepflegt (patch/minor/major)
- `fdroid-metadata/de.jeanlucmakiola.calendula/` Verzeichnis-Struktur
- `LICENSE` = MIT, Jean-Luc Makiola, 2026
- `.planning/` mit `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md`
## 12. Design-Decisions (gelöst)
### Theme-Seed-Color (Fallback wenn kein Dynamic Color verfügbar)
**`0xFF5C6B7A`** - desaturiertes Schiefer-Blaugrau.
- Bewusst anders als HouseHoldKeaper's Sage (`0xFF7A9A6D`), damit beide Apps unterscheidbar sind
- Mid-Saturation → M3 Expressive Dynamic Color generiert daraus eine ausgewogene Palette
- Cool aber nicht kalt → passt zu "modern functional"
- Funktioniert in Light- und Dark-Theme
### App-Icon (Adaptive Launcher)
**Statische "1" auf M3-Expressive-Squircle.**
- **Foreground:** Stilisierte Ziffer "1" (bold), zentriert auf einem Squircle
- **Background:** Seed-Color `0xFF5C6B7A` (slate)
- **Bedeutung:** Die "1" referenziert *kalendae* (der erste Tag des Monats) - Wortwurzel sowohl von "Kalender" als auch "Calendula". Die App heisst Calendula, aber das Icon zeigt klar: dies ist ein Kalender.
- Adaptive-Icon-Spec: Foreground 432dp x 432dp Safe-Zone in 108dp Tile, Background fest
- Vektor-basiert (kein PNG), in `res/drawable/ic_launcher_*.xml` als VectorDrawable
### Konkretes UI-Layout pro Screen
**Bewusst offen** - wird in eigener UI-Design-Iteration nach Spec-Approval entworfen
(Mockups pro Screen, alle drei States, vor Implementation).
## 13. Nächste Schritte nach Spec-Approval
1. Implementation-Plan via `writing-plans`-Skill aus diesem Spec ableiten
2. Initiales Gradle-Projekt-Scaffolding
3. Tooling: Lint-Config, Detekt o.ä., CI-Workflows initial
4. Iterative UI-Design-Phase (Mockups pro Screen, alle drei States,
bevor implementiert wird)
5. Feature-by-Feature-Implementation gegen den Plan