diff --git a/.gitignore b/.gitignore
index 05f0cb2..b170de3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,6 @@ Thumbs.db
# KSP
.ksp/
+
+# Claude Code
+/CLAUDE.md
diff --git a/docs/superpowers/plans/2026-06-08-01-foundation.md b/docs/superpowers/plans/2026-06-08-01-foundation.md
deleted file mode 100644
index 2bae8aa..0000000
--- a/docs/superpowers/plans/2026-06-08-01-foundation.md
+++ /dev/null
@@ -1,2068 +0,0 @@
-# Calendula - Plan 01: Foundation & CI Implementation Plan
-
-> **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:** Liefere ein vollständig konfiguriertes Android-Projektgerüst für Calendula: build-/install-/testbar, mit Material 3 Expressive Theme, korrektem Adaptive-Icon, DE/EN-i18n-Grundgerüst, Hilt, DataStore, und grünen Gitea-CI-Workflows. Am Ende läuft die App und zeigt einen schlichten "Calendula"-Screen mit korrekt themed Surface, das Icon erscheint im Launcher als "1" auf Slate-Squircle, und `./gradlew test lint assembleDebug` ist grün.
-
-**Architecture:** Single Gradle module `:app`, Kotlin 2.1 + Jetpack Compose mit Material 3 Expressive (1.5+), Hilt für DI, DataStore-Preferences für App-Settings. Build via Gradle Kotlin DSL und Version Catalog (`libs.versions.toml`). CI über Gitea Workflows (adaptiert von HouseHoldKeaper, Flutter-Steps durch Gradle-Steps ersetzt). Alle Resourcen + Strings i18n-fähig ab Tag 1 (DE + EN).
-
-**Tech Stack (versions verified 2026-06-08 against canonical registries):**
-- Kotlin 2.3.21 (paired with KSP — Kotlin 2.4.0 has no KSP release yet)
-- Gradle 9.5.1 (bootstrap from HouseHoldKeaper's 8.14 wrapper, then `wrapper --gradle-version 9.5.1`)
-- AGP 9.1.1, JVM Target 17
-- minSdk 29, targetSdk 36, compileSdk 36
-- Jetpack Compose BOM 2026.06.00 (provides Material 3 1.4.0 by default; we override material3 to 1.5.0-alpha21 explicitly)
-- **Material 3 1.5.0-alpha21** — Expressive APIs only exist in 1.5 alpha line; accepting alpha is intentional because Expressive is the whole point of this app
-- Hilt 2.59.2 + KSP 2.3.9
-- AndroidX Core KTX 1.19.0, Lifecycle Runtime KTX 2.10.0, Activity Compose 1.13.0
-- AndroidX DataStore Preferences 1.2.1
-- Tests: JUnit Jupiter 6.1.0 (unified launcher version) + Truth 1.4.5 + Compose UI Test (BOM)
-- AndroidX Test JUnit 1.3.0, Espresso 3.7.0
-- CI: Gitea Actions on Docker runner (Java 17 + Android SDK 36)
-
----
-
-## File Structure
-
-Files this plan creates (all relative to project root `/home/jlmak/Projects/jlmak/cal/`):
-
-**Repo-Meta:**
-- `LICENSE` — MIT
-- `README.md` — Short app description + build instructions
-- `CHANGELOG.md` — Started with `## [Unreleased]` section
-- `.gitignore` — Android Studio + JetBrains + Kotlin/Gradle
-- `.gitattributes` — Line ending normalization
-- `.editorconfig` — Code style
-
-**.planning/ (project tracking docs, HouseHoldKeaper convention):**
-- `.planning/PROJECT.md` — high-level summary
-- `.planning/REQUIREMENTS.md` — feature requirements (validated/active/out-of-scope)
-- `.planning/ROADMAP.md` — V1/V2/V3 milestones
-- `.planning/STATE.md` — current implementation status
-
-**F-Droid metadata:**
-- `fdroid-metadata/de.jeanlucmakiola.calendula.yml`
-- `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/short_description.txt`
-- `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/full_description.txt`
-- `fdroid-metadata/de.jeanlucmakiola.calendula/de/short_description.txt`
-- `fdroid-metadata/de.jeanlucmakiola.calendula/de/full_description.txt`
-
-**Gradle root:**
-- `settings.gradle.kts`
-- `build.gradle.kts`
-- `gradle.properties`
-- `gradle/libs.versions.toml`
-- `gradle/wrapper/gradle-wrapper.properties` (jar generated by `gradle wrapper`)
-
-**App module:**
-- `app/.gitignore`
-- `app/build.gradle.kts`
-- `app/proguard-rules.pro`
-- `app/src/main/AndroidManifest.xml`
-- `app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt`
-- `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt`
-- `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt`
-- `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt`
-- `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt`
-
-**Resources:**
-- `app/src/main/res/values/strings.xml` (English master)
-- `app/src/main/res/values-de/strings.xml` (German)
-- `app/src/main/res/values/colors.xml`
-- `app/src/main/res/values/themes.xml`
-- `app/src/main/res/drawable/ic_launcher_background.xml`
-- `app/src/main/res/drawable/ic_launcher_foreground.xml`
-- `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml`
-- `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml`
-
-**Tests:**
-- `app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt`
-- `app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt`
-
-**CI:**
-- `.gitea/workflows/ci.yaml`
-- `.gitea/workflows/release.yaml`
-
----
-
-## Task 1: Repo Meta Files (LICENSE, README, CHANGELOG, gitignore)
-
-**Files:**
-- Create: `LICENSE`
-- Create: `README.md`
-- Create: `CHANGELOG.md`
-- Create: `.gitignore`
-- Create: `.gitattributes`
-- Create: `.editorconfig`
-
-- [ ] **Step 1: Create `LICENSE` (MIT)**
-
-```
-MIT License
-
-Copyright (c) 2026 Jean-Luc Makiola
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-```
-
-- [ ] **Step 2: Create `README.md`**
-
-```markdown
-# Calendula
-
-A modern Material 3 Expressive calendar app for Android.
-
-Calendula is named after the flower of the same name, whose name comes from
-the Latin *kalendae* — the first day of the month — the same root as the
-word "calendar". Calendula reads from Android's built-in `CalendarContract`,
-so any calendar source synced to your device (CalDAV via DAVx5, Google,
-local, WebCal subscriptions, ...) is shown.
-
-## Features (V1)
-
-- Month, Week, and Day views
-- Read-only event details (write support comes in V2)
-- Multi-calendar visibility toggle
-- Material You Dynamic Color (Android 12+)
-- Light/Dark theme follows system
-- German + English UI
-
-## Building
-
-Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed:
-
-```bash
-# Build debug APK
-./gradlew assembleDebug
-
-# Run unit tests
-./gradlew test
-
-# Run lint
-./gradlew lint
-```
-
-If your default JDK is something other than 17, set `JAVA_HOME` explicitly:
-
-```bash
-JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
-```
-
-## License
-
-[MIT](LICENSE) — Jean-Luc Makiola, 2026
-```
-
-- [ ] **Step 3: Create `CHANGELOG.md`**
-
-```markdown
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-
-### Added
-- Initial project scaffold with Material 3 Expressive theme
-- Adaptive launcher icon (numeral "1" on slate squircle, referencing *kalendae*)
-- German + English localization infrastructure
-- Hilt + DataStore dependency injection and preferences setup
-- Gitea CI/CD workflows for build, test, lint, and release
-```
-
-- [ ] **Step 4: Create `.gitignore`**
-
-```
-# Built application files
-*.apk
-*.aar
-*.ap_
-*.aab
-
-# Files for the ART/Dalvik VM
-*.dex
-
-# Java class files
-*.class
-
-# Generated files
-bin/
-gen/
-out/
-release/
-
-# Gradle files
-.gradle/
-build/
-
-# Local configuration file (sdk path, etc)
-local.properties
-
-# Proguard folder generated by Eclipse
-proguard/
-
-# Log files
-*.log
-
-# Android Studio / IntelliJ
-*.iml
-.idea/
-.navigation/
-captures/
-.externalNativeBuild/
-.cxx/
-
-# Keystore files
-*.jks
-*.keystore
-/key.properties
-
-# Google Services (e.g. APIs or Firebase)
-google-services.json
-
-# OS files
-.DS_Store
-Thumbs.db
-
-# F-Droid local artifacts (the pipeline generates them in CI)
-fdroid/repo/
-fdroid/keystore.p12
-
-# KSP
-.ksp/
-```
-
-- [ ] **Step 5: Create `.gitattributes`**
-
-```
-* text=auto eol=lf
-*.bat text eol=crlf
-*.jar binary
-*.png binary
-*.jpg binary
-*.gif binary
-*.webp binary
-```
-
-- [ ] **Step 6: Create `.editorconfig`**
-
-```ini
-root = true
-
-[*]
-charset = utf-8
-end_of_line = lf
-indent_style = space
-indent_size = 4
-insert_final_newline = true
-trim_trailing_whitespace = true
-
-[*.{yml,yaml,toml,json,md}]
-indent_size = 2
-
-[*.{kt,kts}]
-ij_kotlin_packages_to_use_import_on_demand = unset
-
-[Makefile]
-indent_style = tab
-```
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add LICENSE README.md CHANGELOG.md .gitignore .gitattributes .editorconfig
-git commit -m "chore: add repo meta files (LICENSE, README, CHANGELOG, gitignore)"
-```
-
----
-
-## Task 2: Project Planning Docs (`.planning/`)
-
-Adopts the HouseHoldKeaper convention. These docs live alongside the spec and track higher-level project state.
-
-**Files:**
-- Create: `.planning/PROJECT.md`
-- Create: `.planning/REQUIREMENTS.md`
-- Create: `.planning/ROADMAP.md`
-- Create: `.planning/STATE.md`
-
-- [ ] **Step 1: Create `.planning/PROJECT.md`**
-
-```markdown
-# Calendula
-
-## What This Is
-
-A modern Material 3 Expressive Android calendar app, read-only V1. Lives
-entirely on top of Android's `CalendarContract` — any calendar synced to the
-device (CalDAV via DAVx5, Google, local, WebCal, …) shows up automatically.
-The differentiator is visual: real Material 3 Expressive design that no
-existing FOSS calendar app delivers.
-
-## Core Value
-
-A calendar app that is genuinely pleasant to look at and use, without
-re-inventing the calendar sync stack — leave that to DAVx5 and the system.
-
-## Current Milestone
-
-**v0.1 — Foundation & CI:** Buildable Android project scaffold with theme,
-icon, i18n, Hilt, DataStore, green CI.
-
-## Stack
-
-Kotlin 2.1, Jetpack Compose + Material 3 Expressive, Hilt, DataStore.
-Gradle Kotlin DSL with Version Catalog.
-
-Read-only V1, write support V2.
-
-Android-only (minSdk 29, targetSdk 36). No iOS.
-
-## Naming
-
-"Calendula" — Latin *kalendae* ("first day of the month", root of "calendar")
-is also the etymological root of the marigold flower Calendula. The icon
-shows a stylized "1" on a slate squircle.
-
-## Source
-
-Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
-Hetzner. Same infrastructure as `HouseHoldKeaper`.
-```
-
-- [ ] **Step 2: Create `.planning/REQUIREMENTS.md`**
-
-```markdown
-# Calendula — Requirements
-
-See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
-
-## V1 Scope (Variant "B")
-
-### Validated (shipped)
-- (none yet — first milestone in progress)
-
-### Active (V1)
-
-- [ ] Foundation & CI infrastructure
-- [ ] Data Layer over `CalendarContract`
-- [ ] Permission flow (`READ_CALENDAR`)
-- [ ] Month view (S1)
-- [ ] Week view (S2)
-- [ ] Day view (S3)
-- [ ] Event Detail Sheet (S4)
-- [ ] Multi-Calendar Filter (M3)
-- [ ] Today button + Jump-to-Date (M2)
-- [ ] View-Switcher (M1)
-- [ ] Settings screen (M4)
-- [ ] Empty / no-permission / no-calendars states
-- [ ] German + English localization
-- [ ] Loading/Failure/Success states per screen (architectural pattern)
-
-### Out of Scope (V2+)
-
-- Event create / edit / delete (V2)
-- Home-screen widget
-- Full-text search
-- Quick-add
-- Custom notifications/reminders (system already handles these)
-- Tablet/foldable-specific layouts
-- iOS support (Android-only by design)
-
-## Constraints
-
-- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
-- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
-- **Offline-first:** all data lives in `CalendarContract`; no app-side network
-- **Privacy:** zero telemetry, no analytics
-- **i18n:** German + English from day one
-- **Tests + CI from day one**
-- **License:** MIT
-```
-
-- [ ] **Step 3: Create `.planning/ROADMAP.md`**
-
-```markdown
-# Calendula — Roadmap
-
-## v0.x — Pre-Release
-
-| Version | Milestone | Status |
-|---|---|---|
-| v0.1 | Foundation & CI | in progress |
-| v0.2 | Data Layer & Permission Flow | pending |
-| v0.3 | Month view | pending |
-| v0.4 | Week view | pending |
-| v0.5 | Day view | pending |
-| v0.6 | Event Detail Sheet | pending |
-| v0.7 | Filter & Settings | pending |
-
-## v1.0 — First Public Release
-
-All V1 features shipped, polished, on F-Droid. Read-only calendar.
-
-## v2.0 — Write Support
-
-- Event create / edit / delete via `CalendarContract` writes
-- Quick-add sheet
-- Conflict UX (event modified externally during edit)
-
-## v3.0 — Power-User Features
-
-- Home-screen widget
-- Full-text search
-- Tablet / foldable layouts
-- Optional: ICS file import (drag-and-drop)
-
-Order is indicative — community feedback after V1 may re-prioritize.
-```
-
-- [ ] **Step 4: Create `.planning/STATE.md`**
-
-```markdown
-# Calendula — Current State
-
-*Last updated: 2026-06-08*
-
-## Status
-
-**Milestone:** v0.1 — Foundation & CI
-**Phase:** Implementation starting
-
-## Progress
-
-- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
-- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
-- [x] Plan 01 written (`docs/superpowers/plans/2026-06-08-01-foundation.md`)
-- [ ] Plan 01 executed
-
-## Next
-
-1. Execute Plan 01 to land foundation
-2. Write and execute Plan 02 (Data Layer & Permission Flow)
-3. Iterate on UI design (mockups) before screens are built
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add .planning/
-git commit -m "docs: add .planning/ project-tracking documents"
-```
-
----
-
-## Task 3: F-Droid Metadata Stub
-
-**Files:**
-- Create: `fdroid-metadata/de.jeanlucmakiola.calendula.yml`
-- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/short_description.txt`
-- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/full_description.txt`
-- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/de/short_description.txt`
-- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/de/full_description.txt`
-
-- [ ] **Step 1: Create `fdroid-metadata/de.jeanlucmakiola.calendula.yml`**
-
-```yaml
-AuthorName: Jean-Luc Makiola
-License: MIT
-Name: Calendula
-
-Categories:
- - Time
-
-SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
-IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
-```
-
-- [ ] **Step 2: Create `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/short_description.txt`**
-
-```
-A modern Material 3 Expressive calendar for Android.
-```
-
-- [ ] **Step 3: Create `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/full_description.txt`**
-
-```
-Calendula is a modern, open-source calendar app for Android. It reads from
-the system calendar provider, so any source synced to your device — Nextcloud
-via DAVx5, Google, local, WebCal subscriptions — shows up automatically.
-
-The differentiator is the design: real Material 3 Expressive throughout, with
-dynamic color, expressive motion, and expressive shapes.
-
-V1 is read-only. Event creation, editing, and deletion are planned for V2.
-
-Privacy: zero telemetry, no analytics, no network access — your data never
-leaves the device.
-```
-
-- [ ] **Step 4: Create `fdroid-metadata/de.jeanlucmakiola.calendula/de/short_description.txt`**
-
-```
-Ein moderner Material-3-Expressive-Kalender für Android.
-```
-
-- [ ] **Step 5: Create `fdroid-metadata/de.jeanlucmakiola.calendula/de/full_description.txt`**
-
-```
-Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie liest
-direkt aus dem System-Kalender-Provider — jede Quelle, die mit deinem Gerät
-synchronisiert ist (Nextcloud über DAVx5, Google, lokal, WebCal-Subscriptions)
-erscheint automatisch.
-
-Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
-mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
-
-V1 ist read-only. Erstellen, Bearbeiten und Löschen von Events kommt mit V2.
-
-Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — deine
-Daten bleiben auf dem Gerät.
-```
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add fdroid-metadata/
-git commit -m "chore: add F-Droid metadata for de.jeanlucmakiola.calendula"
-```
-
----
-
-## Task 4: Gradle Root Setup
-
-**Files:**
-- Create: `settings.gradle.kts`
-- Create: `build.gradle.kts`
-- Create: `gradle.properties`
-- Create: `gradle/libs.versions.toml`
-
-- [ ] **Step 1: Create `settings.gradle.kts`**
-
-```kotlin
-pluginManagement {
- repositories {
- google {
- content {
- includeGroupByRegex("com\\.android.*")
- includeGroupByRegex("com\\.google.*")
- includeGroupByRegex("androidx.*")
- }
- }
- mavenCentral()
- gradlePluginPortal()
- }
-}
-
-dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
- repositories {
- google()
- mavenCentral()
- }
-}
-
-rootProject.name = "Calendula"
-include(":app")
-```
-
-- [ ] **Step 2: Create `gradle/libs.versions.toml`**
-
-> All versions verified against canonical registries on 2026-06-08. Material 3 is pinned to alpha intentionally — see the `material3` comment below.
-
-```toml
-[versions]
-agp = "9.1.1"
-kotlin = "2.3.21"
-ksp = "2.3.9"
-hilt = "2.59.2"
-coreKtx = "1.19.0"
-lifecycleRuntime = "2.10.0"
-activityCompose = "1.13.0"
-composeBom = "2026.06.00"
-# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
-# Pin explicitly to override the BOM (which ships stable 1.4.0).
-# Re-evaluate when 1.5.0 stable lands.
-material3 = "1.5.0-alpha21"
-datastore = "1.2.1"
-junit = "6.1.0"
-junitPlatform = "6.1.0"
-truth = "1.4.5"
-androidxJunit = "1.3.0"
-espressoCore = "3.7.0"
-
-[libraries]
-# AndroidX core
-androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
-androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
-androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
-
-# Compose BOM + libs
-androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
-androidx-ui = { group = "androidx.compose.ui", name = "ui" }
-androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
-androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
-androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
-androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
-androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
-
-# Material 3 (Expressive lives in this artifact for 1.5+)
-androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
-
-# Hilt
-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
-
-# DataStore
-androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
-
-# Unit tests
-junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
-junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
-junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" }
-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
-
-# Android tests
-androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
-androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
-
-[plugins]
-android-application = { id = "com.android.application", version.ref = "agp" }
-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
-```
-
-- [ ] **Step 3: Create `build.gradle.kts` (root)**
-
-```kotlin
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-plugins {
- alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
- alias(libs.plugins.kotlin.compose) apply false
- alias(libs.plugins.ksp) apply false
- alias(libs.plugins.hilt) apply false
-}
-```
-
-- [ ] **Step 4: Create `gradle.properties`**
-
-```properties
-# Project-wide Gradle settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-
-# Required for AndroidX.
-android.useAndroidX=true
-
-# Kotlin code style for this project: "official" or "obsolete".
-kotlin.code.style=official
-
-# Enables namespacing of each library's R class, less RAM, faster builds.
-android.nonTransitiveRClass=true
-
-# Use new K2 compiler defaults (Kotlin 2.x).
-kotlin.incremental=true
-
-# Reproducible builds for F-Droid.
-android.uniquePackageNames=true
-```
-
-- [ ] **Step 5: Bootstrap the Gradle wrapper from sibling project**
-
-The dev machine has no host `gradle` installed, but `HouseHoldKeaper/android/` already has a working wrapper (Gradle 8.14). Bootstrap from there:
-
-```bash
-mkdir -p gradle/wrapper
-cp /home/jlmak/Projects/jlmak/HouseHoldKeaper/android/gradlew .
-cp /home/jlmak/Projects/jlmak/HouseHoldKeaper/android/gradlew.bat .
-cp /home/jlmak/Projects/jlmak/HouseHoldKeaper/android/gradle/wrapper/gradle-wrapper.jar gradle/wrapper/
-cp /home/jlmak/Projects/jlmak/HouseHoldKeaper/android/gradle/wrapper/gradle-wrapper.properties gradle/wrapper/
-chmod +x gradlew
-```
-
-- [ ] **Step 6: Upgrade the wrapper to the version AGP 9.1.1 requires**
-
-AGP 9.1.1 mandates Gradle ≥ 9.3.1. Use 9.5.1 (latest stable). This step uses Gradle 8.14 just to upgrade itself — safe because no AGP is wired in yet:
-
-```bash
-JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew wrapper --gradle-version 9.5.1 --distribution-type bin
-```
-
-Expected: Gradle downloads 9.5.1, writes the new `gradle-wrapper.properties` and `gradle-wrapper.jar`. Output ends with `BUILD SUCCESSFUL`.
-
-- [ ] **Step 7: Verify the upgraded wrapper works**
-
-```bash
-JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew --version
-```
-
-Expected output includes:
-```
-Gradle 9.5.1
-```
-
-> Going forward in this plan, every `./gradlew ...` invocation must be prefixed with `JAVA_HOME=/usr/lib/jvm/java-17-openjdk` on this dev machine (the system default is JDK 26, but AGP 9.1.1's compatibility envelope is JDK 17–21). CI sets `setup-java@v4 java-version: 17` so no prefix is needed there.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add settings.gradle.kts build.gradle.kts gradle.properties gradle/
-chmod +x gradlew && git add gradlew gradlew.bat
-git commit -m "chore: add gradle wrapper + version catalog + root build"
-```
-
----
-
-## Task 5: App Module Gradle Setup
-
-**Files:**
-- Create: `app/.gitignore`
-- Create: `app/build.gradle.kts`
-- Create: `app/proguard-rules.pro`
-
-- [ ] **Step 1: Create `app/.gitignore`**
-
-```
-/build
-```
-
-- [ ] **Step 2: Create `app/proguard-rules.pro`**
-
-```
-# Keep Hilt-generated classes
--keep class dagger.hilt.** { *; }
--keep class * extends dagger.hilt.android.HiltAndroidApp
-
-# Compose Compiler may keep its own; defaults are fine
--dontwarn org.jetbrains.annotations.**
-```
-
-- [ ] **Step 3: Create `app/build.gradle.kts`**
-
-```kotlin
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.ksp)
- alias(libs.plugins.hilt)
-}
-
-android {
- namespace = "de.jeanlucmakiola.calendula"
- compileSdk = 36
-
- defaultConfig {
- applicationId = "de.jeanlucmakiola.calendula"
- minSdk = 29
- targetSdk = 36
- versionCode = 1
- versionName = "0.1.0"
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables { useSupportLibrary = true }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- debug {
- applicationIdSuffix = ".debug"
- isMinifyEnabled = false
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- buildFeatures {
- compose = true
- }
-
- packaging {
- resources {
- excludes += "/META-INF/{AL2.0,LGPL2.1}"
- }
- }
-
- testOptions {
- unitTests.all { it.useJUnitPlatform() }
- }
-}
-
-kotlin {
- compilerOptions {
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
- }
-}
-
-dependencies {
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.activity.compose)
-
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.ui)
- implementation(libs.androidx.ui.graphics)
- implementation(libs.androidx.ui.tooling.preview)
- implementation(libs.androidx.material3)
-
- implementation(libs.hilt.android)
- ksp(libs.hilt.compiler)
-
- implementation(libs.androidx.datastore.preferences)
-
- debugImplementation(libs.androidx.ui.tooling)
- debugImplementation(libs.androidx.ui.test.manifest)
-
- testImplementation(libs.junit.jupiter.api)
- testRuntimeOnly(libs.junit.jupiter.engine)
- testRuntimeOnly(libs.junit.platform.launcher)
- testImplementation(libs.truth)
-
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
- androidTestImplementation(platform(libs.androidx.compose.bom))
- androidTestImplementation(libs.androidx.ui.test.junit4)
-}
-```
-
-- [ ] **Step 4: Sync gradle**
-
-```bash
-./gradlew help
-```
-
-Expected: Build succeeds. No project setup errors. Some dependency download output is normal on first run.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/.gitignore app/build.gradle.kts app/proguard-rules.pro
-git commit -m "chore: configure :app module gradle build"
-```
-
----
-
-## Task 6: Android Manifest + Base Resources
-
-**Files:**
-- Create: `app/src/main/AndroidManifest.xml`
-- Create: `app/src/main/res/values/strings.xml`
-- Create: `app/src/main/res/values-de/strings.xml`
-- Create: `app/src/main/res/values/colors.xml`
-- Create: `app/src/main/res/values/themes.xml`
-
-- [ ] **Step 1: Create `app/src/main/AndroidManifest.xml`**
-
-```xml
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-- [ ] **Step 2: Create backup-rules placeholder XML files**
-
-Create `app/src/main/res/xml/backup_rules.xml`:
-
-```xml
-
-
-
-
-```
-
-Create `app/src/main/res/xml/data_extraction_rules.xml`:
-
-```xml
-
-
-
-
-
-
-
-
-```
-
-- [ ] **Step 3: Create `app/src/main/res/values/strings.xml` (English master)**
-
-```xml
-
- Calendula
- A modern calendar.
-
-
- Loading…
- Retry
- Something went wrong.
- Calendar access is required.
- Grant access
- No calendars configured.
- Open system calendar settings
- Could not read the calendar.
-
-```
-
-- [ ] **Step 4: Create `app/src/main/res/values-de/strings.xml`**
-
-```xml
-
- Calendula
- Ein moderner Kalender.
-
- Lädt…
- Erneut versuchen
- Etwas ist schiefgelaufen.
- Zugriff auf den Kalender wird benötigt.
- Zugriff erlauben
- Keine Kalender eingerichtet.
- System-Kalender-Einstellungen öffnen
- Kalender konnte nicht gelesen werden.
-
-```
-
-- [ ] **Step 5: Create `app/src/main/res/values/colors.xml`**
-
-```xml
-
-
- #FF5C6B7A
-
- #FF5C6B7A
-
-```
-
-- [ ] **Step 6: Create `app/src/main/res/values/themes.xml`** (minimal stub — Compose handles real theming)
-
-```xml
-
-
-
-```
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add app/src/main/AndroidManifest.xml app/src/main/res/
-git commit -m "feat: add android manifest, strings (DE+EN), colors, base theme"
-```
-
----
-
-## Task 7: Adaptive Launcher Icon — Static "1" on Slate Squircle
-
-**Files:**
-- Create: `app/src/main/res/drawable/ic_launcher_background.xml`
-- Create: `app/src/main/res/drawable/ic_launcher_foreground.xml`
-- Create: `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml`
-- Create: `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml`
-
-The adaptive icon is 108dp × 108dp, with the inner 72dp safe zone visible (mask applied by launcher). Foreground is centered on a 432dp viewport when designing in vector terms (factor 4× over 108dp).
-
-- [ ] **Step 1: Create `app/src/main/res/drawable/ic_launcher_background.xml`**
-
-A solid slate color background:
-
-```xml
-
-
-
-
-```
-
-- [ ] **Step 2: Create `app/src/main/res/drawable/ic_launcher_foreground.xml`**
-
-A bold "1" centered in the 108dp × 108dp viewport, sized to stay inside the 66dp inner safe zone (launcher masks may crop further). The "1" is drawn as a single-color path with a slight serif foot.
-
-```xml
-
-
-
-
-
-```
-
-- [ ] **Step 3: Create `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml`**
-
-```xml
-
-
-
-
-
-
-```
-
-- [ ] **Step 4: Create `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml`**
-
-```xml
-
-
-
-
-
-
-```
-
-- [ ] **Step 5: Verify build still works**
-
-```bash
-./gradlew assembleDebug
-```
-
-Expected: BUILD SUCCESSFUL. The APK is in `app/build/outputs/apk/debug/`.
-
-> Note: The "1" path above is a simple approximation. The icon will be visually refined during the later UI-design iteration; this is the "ships" version that establishes shape, color, and meaning correctly.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add app/src/main/res/drawable/ic_launcher_background.xml \
- app/src/main/res/drawable/ic_launcher_foreground.xml \
- app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml \
- app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
-git commit -m "feat: adaptive launcher icon — '1' on slate squircle (kalendae)"
-```
-
----
-
-## Task 8: Compose Theme (Color, Theme, Typography) + Unit Test
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt`
-
-- [ ] **Step 1: Write the failing color scheme test**
-
-Create `app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.theme
-
-import androidx.compose.ui.graphics.Color
-import com.google.common.truth.Truth.assertThat
-import org.junit.jupiter.api.Test
-
-class ColorSchemeTest {
-
- @Test
- fun `seed color matches design spec slate`() {
- // The seed color must remain stable - the design is anchored to it.
- // Change this only if the spec is updated.
- assertThat(CalendulaSeed).isEqualTo(Color(0xFF5C6B7A))
- }
-
- @Test
- fun `light fallback scheme uses seed as primary derivation source`() {
- val scheme = CalendulaLightFallback
- // Primary should be a recognizable derivative of the seed (not neutral gray)
- assertThat(scheme.primary).isNotEqualTo(Color.Black)
- assertThat(scheme.primary).isNotEqualTo(Color.White)
- }
-
- @Test
- fun `dark fallback scheme differs from light`() {
- assertThat(CalendulaDarkFallback.background).isNotEqualTo(CalendulaLightFallback.background)
- }
-}
-```
-
-- [ ] **Step 2: Run the failing test**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests "de.jeanlucmakiola.calendula.ui.theme.ColorSchemeTest"
-```
-
-Expected: FAIL with `Unresolved reference: CalendulaSeed` (and similar). Good — the test drives the API.
-
-- [ ] **Step 3: Create `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.theme
-
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.ui.graphics.Color
-
-/**
- * Seed color anchoring the entire palette. See spec section 12.
- * Desaturated slate blue-gray; distinct from HouseHoldKeaper's sage.
- */
-val CalendulaSeed: Color = Color(0xFF5C6B7A)
-
-/**
- * Fallback light scheme used on devices that don't support dynamic color
- * (API < 31) or when the user disables it. The real palette is derived by
- * Material's tonal-palette generator from CalendulaSeed at runtime; these
- * constants are a hand-picked subset that approximates that result.
- */
-val CalendulaLightFallback = lightColorScheme(
- primary = Color(0xFF3B5364),
- onPrimary = Color(0xFFFFFFFF),
- primaryContainer = Color(0xFFCBE6FA),
- onPrimaryContainer = Color(0xFF001E2E),
- secondary = Color(0xFF526070),
- onSecondary = Color(0xFFFFFFFF),
- background = Color(0xFFFBFCFE),
- onBackground = Color(0xFF191C1F),
- surface = Color(0xFFFBFCFE),
- onSurface = Color(0xFF191C1F),
-)
-
-val CalendulaDarkFallback = darkColorScheme(
- primary = Color(0xFFA3CBE2),
- onPrimary = Color(0xFF003348),
- primaryContainer = Color(0xFF21495F),
- onPrimaryContainer = Color(0xFFCBE6FA),
- secondary = Color(0xFFB9C8DA),
- onSecondary = Color(0xFF243240),
- background = Color(0xFF101316),
- onBackground = Color(0xFFE1E3E6),
- surface = Color(0xFF101316),
- onSurface = Color(0xFFE1E3E6),
-)
-```
-
-- [ ] **Step 4: Run the test to verify it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests "de.jeanlucmakiola.calendula.ui.theme.ColorSchemeTest"
-```
-
-Expected: 3 tests PASS.
-
-- [ ] **Step 5: Create `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt`**
-
-For V1 we use Compose Material 3 defaults. Refinement (custom font, expressive type scale) happens later in the UI-design iteration.
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.theme
-
-import androidx.compose.material3.Typography
-
-/**
- * Default Material 3 Expressive typography. Custom font + tuned scale will
- * land in a later UI-design iteration; the defaults are intentional for V1
- * scaffolding to keep the foundation lean.
- */
-val CalendulaTypography = Typography()
-```
-
-- [ ] **Step 6: Create `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.theme
-
-import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-
-/**
- * App theme. Honors:
- * - System light/dark.
- * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
- * derived from [CalendulaSeed].
- *
- * The Settings screen (later) can override useDynamicColor and themePreference,
- * but the V1 foundation just follows the system.
- */
-@Composable
-fun CalendulaTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- dynamicColor: Boolean = true,
- content: @Composable () -> Unit,
-) {
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val ctx = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
- }
- darkTheme -> CalendulaDarkFallback
- else -> CalendulaLightFallback
- }
-
- MaterialTheme(
- colorScheme = colorScheme,
- typography = CalendulaTypography,
- content = content,
- )
-}
-```
-
-- [ ] **Step 7: Verify everything builds**
-
-```bash
-./gradlew assembleDebug
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/ \
- app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/
-git commit -m "feat: M3 Expressive theme with dynamic color + fallback scheme from slate seed"
-```
-
----
-
-## Task 9: Application Class (Hilt entry point) + MainActivity
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt`
-
-- [ ] **Step 1: Create `app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula
-
-import android.app.Application
-import dagger.hilt.android.HiltAndroidApp
-
-/**
- * Application entry point. Registered as android:name=".CalendulaApp"
- * in AndroidManifest.xml. Hilt initializes its component graph here.
- */
-@HiltAndroidApp
-class CalendulaApp : Application()
-```
-
-- [ ] **Step 2: Create `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import dagger.hilt.android.AndroidEntryPoint
-import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
-
-@AndroidEntryPoint
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContent {
- CalendulaTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- PlaceholderScreen(modifier = Modifier.padding(innerPadding))
- }
- }
- }
- }
-}
-
-@Composable
-private fun PlaceholderScreen(modifier: Modifier = Modifier) {
- Column(
- modifier = modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- text = stringResource(R.string.app_name),
- style = MaterialTheme.typography.displayMedium,
- )
- Text(
- text = stringResource(R.string.app_tagline),
- style = MaterialTheme.typography.bodyLarge,
- )
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun PlaceholderPreview() {
- CalendulaTheme { PlaceholderScreen() }
-}
-```
-
-- [ ] **Step 3: Build & install on a device or emulator (manual sanity check)**
-
-```bash
-./gradlew installDebug
-```
-
-Expected: APK installs as "Calendula" with the slate "1" icon. Launching it shows "Calendula" + "A modern calendar." centered on themed background.
-
-> If you don't have a device handy, just `./gradlew assembleDebug` and move on; the UI test in Task 10 covers behavior.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt \
- app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
-git commit -m "feat: scaffold CalendulaApp + MainActivity with themed placeholder"
-```
-
----
-
-## Task 10: Smoke UI Test
-
-**Files:**
-- Create: `app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt`
-
-- [ ] **Step 1: Write the failing UI test**
-
-```kotlin
-package de.jeanlucmakiola.calendula
-
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class MainActivitySmokeTest {
-
- @get:Rule
- val composeTestRule = createAndroidComposeRule()
-
- @Test
- fun appName_isDisplayed_onLaunch() {
- composeTestRule.onNodeWithText("Calendula").assertIsDisplayed()
- }
-
- @Test
- fun tagline_isDisplayed_onLaunch() {
- composeTestRule.onNodeWithText("A modern calendar.").assertIsDisplayed()
- }
-}
-```
-
-- [ ] **Step 2: Verify the test runs (requires emulator/device)**
-
-```bash
-./gradlew :app:connectedDebugAndroidTest
-```
-
-Expected: 2 tests PASS. If no emulator is running, this step is informational only — the CI emulator runs it instead.
-
-> If running locally without an emulator: skip `connectedDebugAndroidTest` for now; CI will catch failures.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt
-git commit -m "test: add UI smoke test for MainActivity placeholder"
-```
-
----
-
-## Task 11: CI Workflow (`.gitea/workflows/ci.yaml`)
-
-**Files:**
-- Create: `.gitea/workflows/ci.yaml`
-
-- [ ] **Step 1: Create `.gitea/workflows/ci.yaml`**
-
-```yaml
-name: CI
-
-on:
- push:
- branches:
- - '**'
- tags-ignore:
- - '**'
- pull_request:
-
-jobs:
- ci:
- runs-on: docker
- env:
- ANDROID_HOME: /opt/android-sdk
- ANDROID_SDK_ROOT: /opt/android-sdk
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Java
- uses: actions/setup-java@v4
- with:
- distribution: 'zulu'
- java-version: '17'
-
- - name: Setup Android SDK
- uses: android-actions/setup-android@v3
-
- - name: Install Android SDK packages
- run: |
- sdkmanager --licenses >/dev/null <<'EOF'
- y
- y
- y
- y
- y
- y
- y
- y
- y
- y
- EOF
- sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
-
- - name: Install jq
- run: |
- set -e
- SUDO=""
- if command -v sudo >/dev/null 2>&1; then
- SUDO="sudo"
- fi
- if command -v apt-get >/dev/null 2>&1; then
- $SUDO apt-get update
- $SUDO apt-get install -y jq
- elif command -v apk >/dev/null 2>&1; then
- $SUDO apk add --no-cache jq
- fi
-
- - name: Setup Gradle cache
- uses: actions/cache@v4
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - name: Grant execute permission for gradlew
- run: chmod +x ./gradlew
-
- - name: Lint
- run: ./gradlew lint --no-daemon
-
- - name: Unit tests
- run: ./gradlew test --no-daemon
-
- - name: Assemble debug APK
- run: ./gradlew assembleDebug --no-daemon
-
- - name: Trivy filesystem scan
- run: |
- set -e
- SUDO=""
- if command -v sudo >/dev/null 2>&1; then
- SUDO="sudo"
- fi
- if command -v apt-get >/dev/null 2>&1; then
- $SUDO apt-get update
- $SUDO apt-get install -y wget apt-transport-https gnupg lsb-release
- wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
- echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
- $SUDO apt-get update
- $SUDO apt-get install -y trivy
- fi
- trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
- continue-on-error: true
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add .gitea/workflows/ci.yaml
-git commit -m "ci: add gitea CI workflow (lint, test, assemble, trivy)"
-```
-
----
-
-## Task 12: Release Workflow (`.gitea/workflows/release.yaml`)
-
-**Files:**
-- Create: `.gitea/workflows/release.yaml`
-
-This adapts the HouseHoldKeaper release workflow for Gradle/Android. Keystore secrets (`KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`, `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS`) must be configured in Gitea repo settings before the first tag is pushed.
-
-- [ ] **Step 1: Create `.gitea/workflows/release.yaml`**
-
-```yaml
-name: Build and Release to F-Droid
-
-on:
- push:
- tags:
- - '*'
- workflow_dispatch:
-
-jobs:
- ci:
- runs-on: docker
- env:
- ANDROID_HOME: /opt/android-sdk
- ANDROID_SDK_ROOT: /opt/android-sdk
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Java
- uses: actions/setup-java@v4
- with:
- distribution: 'zulu'
- java-version: '17'
-
- - name: Setup Android SDK
- uses: android-actions/setup-android@v3
-
- - name: Install Android SDK packages
- run: |
- sdkmanager --licenses >/dev/null <<'EOF'
- y
- y
- y
- y
- y
- y
- y
- y
- y
- y
- EOF
- sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
-
- - name: Grant execute permission for gradlew
- run: chmod +x ./gradlew
-
- - name: Lint + tests + debug build (sanity)
- run: ./gradlew lint test assembleDebug --no-daemon
-
- build-and-deploy:
- needs: ci
- runs-on: docker
- env:
- ANDROID_HOME: /opt/android-sdk
- ANDROID_SDK_ROOT: /opt/android-sdk
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Java
- uses: actions/setup-java@v4
- with:
- distribution: 'zulu'
- java-version: '17'
-
- - name: Setup Android SDK
- uses: android-actions/setup-android@v3
-
- - name: Install Android SDK packages
- run: |
- sdkmanager --licenses >/dev/null <<'EOF'
- y
- y
- y
- y
- y
- y
- y
- y
- y
- y
- EOF
- sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
-
- - name: Install jq
- run: |
- set -e
- SUDO=""
- if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
- if command -v apt-get >/dev/null 2>&1; then
- $SUDO apt-get update
- $SUDO apt-get install -y jq
- elif command -v apk >/dev/null 2>&1; then
- $SUDO apk add --no-cache jq
- fi
-
- - name: Set version from git tag
- run: |
- set -e
- RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
- VERSION="${RAW_TAG#v}"
- MAJOR=$(echo "$VERSION" | cut -d. -f1)
- MINOR=$(echo "$VERSION" | cut -d. -f2)
- PATCH=$(echo "$VERSION" | cut -d. -f3)
- MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
- VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
- echo "Version: $VERSION, VersionCode: $VERSION_CODE"
- sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
- sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
- grep -E 'versionName|versionCode' app/build.gradle.kts
-
- - name: Setup Android Keystore
- env:
- KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
- KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
- run: |
- mkdir -p app
- echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
- cat > key.properties </dev/null 2>&1; then SUDO="sudo"; fi
- $SUDO apt-get update
- $SUDO apt-get install -y sshpass python3-pip
- pip3 install --break-system-packages --upgrade fdroidserver
-
- - name: Initialize or fetch F-Droid Repository
- env:
- HOST: ${{ secrets.HETZNER_HOST }}
- USER: ${{ secrets.HETZNER_USER }}
- PASS: ${{ secrets.HETZNER_PASS }}
- run: |
- mkdir -p fdroid
- sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
- -mkdir dev
- -mkdir dev/fdroid
- -mkdir dev/fdroid/repo
- SFTP
- sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
-
- - name: Ensure F-Droid repo signing key and icon
- run: |
- cd fdroid
- mkdir -p repo/icons
- if [ ! -f repo/icons/icon.png ]; then
- # Fallback: copy a placeholder; until a PNG icon ships,
- # F-Droid uses a generic icon. Real icon comes when we have a PNG export.
- true
- fi
- if [ ! -f keystore.p12 ]; then
- fdroid update --create-key
- fi
-
- - name: Copy new APK to repo
- run: |
- set -e
- mkdir -p fdroid/repo
- REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
- SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
- if [ -z "$SAFE_REF_NAME" ]; then
- SAFE_REF_NAME="${GITHUB_SHA:-manual}"
- fi
- cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
-
- - name: Copy metadata to F-Droid repo
- run: |
- mkdir -p fdroid/metadata
- cp -r fdroid-metadata/* fdroid/metadata/
-
- - name: Generate F-Droid Index
- run: |
- cd fdroid
- fdroid update -c
-
- - name: Upload Repo to Hetzner
- env:
- HOST: ${{ secrets.HETZNER_HOST }}
- USER: ${{ secrets.HETZNER_USER }}
- PASS: ${{ secrets.HETZNER_PASS }}
- run: |
- set -euo pipefail
- SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
- sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
- -mkdir dev
- -mkdir dev/fdroid
- -mkdir dev/fdroid/repo
- SFTP
- sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
-```
-
-> Note: The release workflow assumes `app/build.gradle.kts` will pick up signing config from a `key.properties` file at the project root. We add that wiring in Task 13.
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add .gitea/workflows/release.yaml
-git commit -m "ci: add gitea release workflow with F-Droid pipeline"
-```
-
----
-
-## Task 13: Wire Signing Config into App Gradle
-
-**Files:**
-- Modify: `app/build.gradle.kts`
-
-The release workflow drops a `key.properties` at project root and a `upload-keystore.jks` in `app/`. Make Gradle read these.
-
-- [ ] **Step 1: Replace the contents of `app/build.gradle.kts`**
-
-```kotlin
-import java.util.Properties
-import java.io.FileInputStream
-
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.ksp)
- alias(libs.plugins.hilt)
-}
-
-val keystorePropertiesFile = rootProject.file("key.properties")
-val keystoreProperties = Properties().apply {
- if (keystorePropertiesFile.exists()) {
- load(FileInputStream(keystorePropertiesFile))
- }
-}
-
-android {
- namespace = "de.jeanlucmakiola.calendula"
- compileSdk = 36
-
- defaultConfig {
- applicationId = "de.jeanlucmakiola.calendula"
- minSdk = 29
- targetSdk = 36
- versionCode = 1
- versionName = "0.1.0"
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables { useSupportLibrary = true }
- }
-
- signingConfigs {
- if (keystorePropertiesFile.exists()) {
- create("release") {
- keyAlias = keystoreProperties["keyAlias"] as String
- keyPassword = keystoreProperties["keyPassword"] as String
- storeFile = file(keystoreProperties["storeFile"] as String)
- storePassword = keystoreProperties["storePassword"] as String
- }
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- if (keystorePropertiesFile.exists()) {
- signingConfig = signingConfigs.getByName("release")
- }
- }
- debug {
- applicationIdSuffix = ".debug"
- isMinifyEnabled = false
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- buildFeatures {
- compose = true
- }
-
- packaging {
- resources {
- excludes += "/META-INF/{AL2.0,LGPL2.1}"
- }
- }
-
- testOptions {
- unitTests.all { it.useJUnitPlatform() }
- }
-}
-
-kotlin {
- compilerOptions {
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
- }
-}
-
-dependencies {
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.activity.compose)
-
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.ui)
- implementation(libs.androidx.ui.graphics)
- implementation(libs.androidx.ui.tooling.preview)
- implementation(libs.androidx.material3)
-
- implementation(libs.hilt.android)
- ksp(libs.hilt.compiler)
-
- implementation(libs.androidx.datastore.preferences)
-
- debugImplementation(libs.androidx.ui.tooling)
- debugImplementation(libs.androidx.ui.test.manifest)
-
- testImplementation(libs.junit.jupiter.api)
- testRuntimeOnly(libs.junit.jupiter.engine)
- testRuntimeOnly(libs.junit.platform.launcher)
- testImplementation(libs.truth)
-
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
- androidTestImplementation(platform(libs.androidx.compose.bom))
- androidTestImplementation(libs.androidx.ui.test.junit4)
-}
-```
-
-- [ ] **Step 2: Verify debug build still works (without keystore)**
-
-```bash
-./gradlew assembleDebug
-```
-
-Expected: BUILD SUCCESSFUL. The release block silently skips signing config when no `key.properties` is present.
-
-- [ ] **Step 3: Verify release build skips signing locally**
-
-```bash
-./gradlew assembleRelease
-```
-
-Expected: BUILD SUCCESSFUL with a warning that the release APK is unsigned (or signed with debug key) — this is fine; only CI signs releases.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add app/build.gradle.kts
-git commit -m "build: wire signing config from key.properties when present"
-```
-
----
-
-## Task 14: Final Verification & CHANGELOG Update
-
-- [ ] **Step 1: Run the full local verification**
-
-```bash
-./gradlew lint test assembleDebug
-```
-
-Expected: All three steps complete with BUILD SUCCESSFUL.
-
-- [ ] **Step 2: Update `CHANGELOG.md`**
-
-Replace the existing `[Unreleased]` block with explicit shipped items for v0.1.0 and a new (empty) `[Unreleased]` placeholder. Final file content:
-
-```markdown
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-
-## [0.1.0] — 2026-06-08
-
-### Added
-- Initial project scaffold (Gradle Kotlin DSL, Version Catalog, Hilt, DataStore)
-- Material 3 Expressive theme with Dynamic Color (API 31+) and slate-derived fallback
-- Adaptive launcher icon — stylized "1" on slate squircle (references *kalendae*)
-- German + English localization infrastructure
-- Permission declaration for `READ_CALENDAR` (no UI flow yet — that's Plan 02)
-- Gitea CI workflow: lint, unit tests, debug build, Trivy scan
-- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
-- F-Droid metadata stubs (DE + EN short/full descriptions)
-- `.planning/` project-tracking documents
-```
-
-- [ ] **Step 3: Update `.planning/STATE.md`**
-
-```markdown
-# Calendula — Current State
-
-*Last updated: 2026-06-08*
-
-## Status
-
-**Milestone:** v0.1 — Foundation & CI
-**Phase:** Plan 01 complete; ready to start Plan 02
-
-## Progress
-
-- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
-- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
-- [x] Plan 01 written and executed (`docs/superpowers/plans/2026-06-08-01-foundation.md`)
-- [x] Foundation lands: theme, icon, i18n, Hilt, DataStore, CI green
-- [ ] Plan 02 written (Data Layer & Permission Flow)
-
-## Next
-
-1. Write Plan 02: Data Layer & Permission Flow
-2. Execute Plan 02
-3. Iterate on UI design (mockups) before screens are built
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add CHANGELOG.md .planning/STATE.md
-git commit -m "docs: record v0.1.0 foundation in CHANGELOG, update STATE"
-```
-
-- [ ] **Step 5: Tag v0.1.0 — Optional, only after CI passes on Gitea**
-
-This step happens only once the repo is pushed to Gitea, CI is configured, and CI runs green on the foundation commit. Skip locally.
-
-```bash
-# After CI is green on Gitea:
-git tag -a v0.1.0 -m "v0.1.0 — foundation"
-git push origin v0.1.0
-```
-
-The release workflow will then build, sign, and publish to F-Droid.
-
----
-
-## Verification Checklist (post-execution)
-
-After all 14 tasks are completed, verify:
-
-- [ ] `./gradlew lint` exits 0
-- [ ] `./gradlew test` exits 0; all unit tests pass (ColorSchemeTest x3)
-- [ ] `./gradlew assembleDebug` produces a working APK at `app/build/outputs/apk/debug/app-debug.apk`
-- [ ] APK installs and shows "Calendula" + "A modern calendar." in the system language (DE if locale=de_DE, else EN)
-- [ ] Launcher icon shows the "1" on slate squircle
-- [ ] Theme respects system light/dark
-- [ ] On API 31+ with a colored wallpaper, theme picks up wallpaper colors (Dynamic Color)
-- [ ] CI workflow file is at `.gitea/workflows/ci.yaml`
-- [ ] Release workflow file is at `.gitea/workflows/release.yaml`
-- [ ] F-Droid metadata is in `fdroid-metadata/de.jeanlucmakiola.calendula/`
-- [ ] `.planning/STATE.md` reflects "Plan 01 complete"
-
-When pushing to Gitea for the first time:
-
-- [ ] Configure repo secrets: `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`, `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS`
-- [ ] First CI run is green on `main`
-- [ ] Tag `v0.1.0` triggers the release workflow successfully
-- [ ] F-Droid repo at `https:///dev/fdroid/` shows Calendula
-
----
-
-## What Plan 01 Does NOT Do (deferred to subsequent plans)
-
-- No `CalendarRepository`, no `ContentResolver` queries → Plan 02
-- No permission UI flow → Plan 02
-- No calendar event reading → Plan 02
-- No Month / Week / Day views → Plans 03 / 04 / 05
-- No Event-Detail-Sheet → Plan 06
-- No Filter / Settings → Plan 07
-- The "1" icon path is geometrically simple; visual refinement happens during UI-design iteration before V1 release
-- Custom typography / fonts → UI-design iteration
-
-The foundation deliberately ships small. Every subsequent plan layers one focused feature on top of a known-good base.
diff --git a/docs/superpowers/plans/2026-06-08-02-data-layer-and-permission-flow.md b/docs/superpowers/plans/2026-06-08-02-data-layer-and-permission-flow.md
deleted file mode 100644
index 5113c61..0000000
--- a/docs/superpowers/plans/2026-06-08-02-data-layer-and-permission-flow.md
+++ /dev/null
@@ -1,3177 +0,0 @@
-# Calendula - Plan 02: Data Layer & Permission Flow Implementation Plan
-
-> **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:** Liefere eine voll funktionsfähige Daten-Pipeline aus dem `CalendarContract`-Provider plus den vollständigen `READ_CALENDAR`-Permission-Flow. Nach Plan 02 startet die App, fragt nach Kalender-Zugriff, und zeigt auf dem (wegwerfbaren) Debug-Screen eine Liste aller Kalender plus die nächsten 50 Event-Instanzen ab heute. `./gradlew lint test assembleDebug` ist grün, ContentObserver triggert Live-Updates, Unit-Tests decken alle defensiven Validierungen aus Spec §8 ab, ein Instrumented-Test fährt gegen den echten Provider.
-
-**Architecture:** Drei Layer ohne Vermischung: `domain/` ist pure Kotlin (keine Android-Imports, `kotlinx.datetime` für Zeitstempel). `data/` kennt `ContentResolver`, `Cursor` und `CalendarContract`; sie übersetzt zwischen `java.time` an der Provider-Grenze und `kotlinx.datetime` im Domain. `ui/` hängt nur an Domain + Repository-Interface, niemals an `Cursor`. Repository ist `@Singleton`, hält einen `ContentObserver` auf `CalendarContract.CONTENT_URI` und re-emittiert über `SharedFlow`. App-eigene ausgeblendete Kalender-IDs liegen als `Set` in DataStore. Permission-Flow ist ein eigener Screen (Rationale → Request → Granted | Denied → Recovery), gerouted in `MainActivity` über einen einfachen `sealed`-State (keine Compose-Navigation, YAGNI).
-
-**Tech Stack (versions verified 2026-06-08 against canonical registries):**
-- `kotlinx-datetime` 0.7.0 — Domain Instant/LocalDate
-- `kotlinx-coroutines-core` 1.10.2 — SharedFlow, combine, Dispatchers.IO
-- `kotlinx-coroutines-test` 1.10.2 (test) — TestDispatcher, runTest
-- `app.cash.turbine` 1.2.0 (test) — Flow-Assertions
-- `androidx.hilt:hilt-navigation-compose` 1.3.0 — `hiltViewModel()` in Composables
-- `androidx.lifecycle:lifecycle-runtime-compose` 2.10.0 — `collectAsStateWithLifecycle`
-- `androidx.test:rules` 1.7.0 (androidTest) — `GrantPermissionRule`
-- Existing stack from Plan 01 unchanged (Kotlin 2.3.21, AGP 9.1.1, Compose BOM 2026.05.01, Material 3 1.5.0-alpha21, Hilt 2.59.2, DataStore 1.2.1, JUnit Jupiter 6.1.0, Truth 1.4.5)
-
----
-
-## File Structure
-
-Files this plan creates or modifies (all relative to project root `/home/jlmak/Projects/jlmak/cal/`):
-
-**Build:**
-- Modify: `gradle/libs.versions.toml` — add 7 new artifacts + versions
-- Modify: `app/build.gradle.kts` — wire new dependencies
-
-**Domain (pure Kotlin):**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt`
-
-**Data layer (`data/`):**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridge.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarContentResolver.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt`
-
-**UI - Permission Flow:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionUiState.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionViewModel.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt`
-
-**UI - Debug Screen (wegwerfbar, fliegt mit Plan 03 raus):**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugScreen.kt`
-
-**Entry point:**
-- Modify: `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt` — Permission-State-Routing
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt`
-
-**Resources:**
-- Modify: `app/src/main/res/values/strings.xml`
-- Modify: `app/src/main/res/values-de/strings.xml`
-
-**Unit tests (JVM, JUnit5 + Truth + Turbine):**
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridgeTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarContentResolver.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt`
-
-**Instrumented tests (androidTest, JUnit4 + Compose UI Test):**
-- Create: `app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt`
-- Create: `app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt`
-- Modify: `app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt` — replace placeholder asserts with permission-flow asserts
-
-**Docs:**
-- Modify: `CHANGELOG.md`
-- Modify: `.planning/STATE.md`
-- Modify: `.planning/ROADMAP.md`
-- Modify: `.planning/REQUIREMENTS.md`
-
----
-
-## Task 1: Add Dependencies (Catalog + Build Script)
-
-**Files:**
-- Modify: `gradle/libs.versions.toml`
-- Modify: `app/build.gradle.kts`
-
-- [ ] **Step 1: Append new versions and libraries to `gradle/libs.versions.toml`**
-
-Open `gradle/libs.versions.toml`. In the `[versions]` block, add these lines (alphabetical order, after the last existing entry):
-
-```toml
-kotlinxDatetime = "0.7.0"
-kotlinxCoroutines = "1.10.2"
-turbine = "1.2.0"
-hiltNavigationCompose = "1.3.0"
-lifecycleCompose = "2.10.0"
-androidxTestRules = "1.7.0"
-```
-
-In the `[libraries]` block, append these lines after the existing "Android tests" section:
-
-```toml
-# Domain time
-kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
-
-# Coroutines (transitively pulled by hilt-android, but pinned explicit)
-kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
-kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
-
-# Test - Flow assertions
-turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
-
-# Hilt navigation-compose (for hiltViewModel() in Composables)
-androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
-
-# Lifecycle compose (for collectAsStateWithLifecycle)
-androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
-
-# Android tests - GrantPermissionRule
-androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
-```
-
-- [ ] **Step 2: Wire new deps into `app/build.gradle.kts` and enable Android-stub defaults in unit tests**
-
-The new tests touch `android.util.Log` (defensive logging in mappers) and `android.database.MatrixCursor`. JVM unit tests load the AGP "mockable android.jar"; we need `isReturnDefaultValues = true` so `Log.w()` no-ops instead of throwing `RuntimeException("Stub!")`. Update `testOptions` and add the new dependency lines.
-
-In `app/build.gradle.kts`, locate the existing `testOptions` block:
-
-```kotlin
-testOptions {
- unitTests.all { it.useJUnitPlatform() }
-}
-```
-
-Replace it with:
-
-```kotlin
-testOptions {
- unitTests {
- all { it.useJUnitPlatform() }
- isReturnDefaultValues = true
- }
-}
-```
-
-Then in the `dependencies { ... }` block, the existing implementations stay. Append the new lines so the full dependencies block reads:
-
-```kotlin
-dependencies {
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.androidx.activity.compose)
-
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.ui)
- implementation(libs.androidx.ui.graphics)
- implementation(libs.androidx.ui.tooling.preview)
- implementation(libs.androidx.material3)
-
- implementation(libs.hilt.android)
- implementation(libs.androidx.hilt.navigation.compose)
- ksp(libs.hilt.compiler)
-
- implementation(libs.androidx.datastore.preferences)
-
- implementation(libs.kotlinx.datetime)
- implementation(libs.kotlinx.coroutines.core)
-
- debugImplementation(libs.androidx.ui.tooling)
- debugImplementation(libs.androidx.ui.test.manifest)
-
- testImplementation(libs.junit.jupiter.api)
- testRuntimeOnly(libs.junit.jupiter.engine)
- testRuntimeOnly(libs.junit.platform.launcher)
- testImplementation(libs.truth)
- testImplementation(libs.turbine)
- testImplementation(libs.kotlinx.coroutines.test)
-
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
- androidTestImplementation(libs.androidx.test.rules)
- androidTestImplementation(platform(libs.androidx.compose.bom))
- androidTestImplementation(libs.androidx.ui.test.junit4)
-}
-```
-
-- [ ] **Step 3: Sync and verify catalog parses + dependency graph resolves**
-
-Run:
-
-```bash
-./gradlew :app:dependencies --configuration debugRuntimeClasspath -q | head -60
-```
-
-Expected: a tree printing without errors, and the following groups appear somewhere in the output: `org.jetbrains.kotlinx:kotlinx-datetime:0.7.0`, `org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2`, `androidx.hilt:hilt-navigation-compose:1.3.0`, `androidx.lifecycle:lifecycle-runtime-compose:2.10.0`.
-
-If you see `Could not resolve` or `Unresolved reference: libs.…`, you typed an artifact name wrong in the catalog. Fix and re-run.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add gradle/libs.versions.toml app/build.gradle.kts
-git commit -m "build: add kotlinx-datetime, coroutines, turbine, hilt-nav-compose, lifecycle-compose"
-```
-
----
-
-## Task 2: Time Bridge (java.time ↔ kotlinx.datetime)
-
-The provider returns `Long` epoch-millis. Domain holds `kotlinx.datetime.Instant`. One tiny module to bridge.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridge.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridgeTest.kt`
-
-- [ ] **Step 1: Write the failing test**
-
-Create `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridgeTest.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import com.google.common.truth.Truth.assertThat
-import kotlinx.datetime.Instant
-import org.junit.jupiter.api.Test
-
-class TimeBridgeTest {
-
- @Test
- fun `epoch millis round-trips through Instant`() {
- val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
- val instant = original.toKotlinInstantFromEpochMillis()
- assertThat(instant.toEpochMillis()).isEqualTo(original)
- }
-
- @Test
- fun `zero millis maps to Instant epoch`() {
- assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
- }
-
- @Test
- fun `negative epoch millis is supported`() {
- val original = -1_000_000L
- assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
- }
-}
-```
-
-- [ ] **Step 2: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.TimeBridgeTest'
-```
-
-Expected: FAIL with `Unresolved reference: toKotlinInstantFromEpochMillis` (or compilation error).
-
-- [ ] **Step 3: Implement TimeBridge.kt**
-
-Create `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridge.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import kotlinx.datetime.Instant
-
-/**
- * Provider exposes timestamps as Long epoch-millis. Domain uses kotlinx.datetime.
- * These two extensions are the only sanctioned bridge.
- */
-
-fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
-
-fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
-```
-
-- [ ] **Step 4: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.TimeBridgeTest'
-```
-
-Expected: PASS, 3 tests green.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridge.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/TimeBridgeTest.kt
-git commit -m "data: add TimeBridge helpers for epoch-millis ↔ kotlinx.datetime"
-```
-
----
-
-## Task 3: Domain Models (pure Kotlin)
-
-All domain types in one file. Pure Kotlin, no Android imports.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt`
-
-- [ ] **Step 1: Write the failing test**
-
-Create `app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.domain
-
-import com.google.common.truth.Truth.assertThat
-import kotlinx.datetime.Instant
-import org.junit.jupiter.api.Test
-
-class ModelsTest {
-
- @Test
- fun `CalendarSource is a data class with structural equality`() {
- val a = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
- val b = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
- assertThat(a).isEqualTo(b)
- }
-
- @Test
- fun `EventInstance is a data class with structural equality`() {
- val start = Instant.fromEpochMilliseconds(0L)
- val end = Instant.fromEpochMilliseconds(3_600_000L)
- val a = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
- val b = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
- assertThat(a).isEqualTo(b)
- }
-
- @Test
- fun `AttendeeStatus enum has all five variants`() {
- assertThat(AttendeeStatus.values().toSet()).isEqualTo(
- setOf(
- AttendeeStatus.Accepted,
- AttendeeStatus.Declined,
- AttendeeStatus.Tentative,
- AttendeeStatus.NeedsAction,
- AttendeeStatus.Unknown,
- )
- )
- }
-
- @Test
- fun `FailureReason enum has all five variants`() {
- assertThat(FailureReason.values().toSet()).isEqualTo(
- setOf(
- FailureReason.PermissionRevoked,
- FailureReason.NoCalendarsConfigured,
- FailureReason.ProviderUnavailable,
- FailureReason.EventNotFound,
- FailureReason.Unknown,
- )
- )
- }
-
- @Test
- fun `EventDetail composes EventInstance plus extras`() {
- val instance = EventInstance(
- instanceId = 10L,
- eventId = 1L,
- calendarId = 1L,
- title = "Meet",
- start = Instant.fromEpochMilliseconds(0L),
- end = Instant.fromEpochMilliseconds(60_000L),
- isAllDay = false,
- color = 0xFFAABBCC.toInt(),
- location = null,
- )
- val detail = EventDetail(
- instance = instance,
- description = "Brief description",
- organizer = "x@y",
- attendees = listOf(Attendee("Alice", "alice@x", AttendeeStatus.Accepted)),
- rrule = "FREQ=WEEKLY",
- )
- assertThat(detail.instance.title).isEqualTo("Meet")
- assertThat(detail.attendees).hasSize(1)
- }
-}
-```
-
-- [ ] **Step 2: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.domain.ModelsTest'
-```
-
-Expected: FAIL with `Unresolved reference: CalendarSource` (or similar).
-
-- [ ] **Step 3: Implement domain models**
-
-Create `app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.domain
-
-import kotlinx.datetime.Instant
-
-/**
- * A configured calendar source on the device (e.g. a Nextcloud DAVx5 calendar,
- * a Google account calendar, or a local calendar). Maps 1:1 to a row in
- * CalendarContract.Calendars.
- */
-data class CalendarSource(
- val id: Long,
- val displayName: String,
- val accountName: String,
- val accountType: String,
- val color: Int,
- val isVisibleInSystem: Boolean,
-)
-
-/**
- * One concrete occurrence of an event in time. For non-recurring events the
- * instanceId is unique per event. For recurring events there is one
- * EventInstance per occurrence within the queried time range; eventId is
- * shared across all occurrences of the same event.
- *
- * color is the effective color: event.color if set, else calendar.color.
- */
-data class EventInstance(
- val instanceId: Long,
- val eventId: Long,
- val calendarId: Long,
- val title: String,
- val start: Instant,
- val end: Instant,
- val isAllDay: Boolean,
- val color: Int,
- val location: String?,
-)
-
-/**
- * Read-only detail of a single event. `instance` is reused here for the basic
- * fields; description / organizer / attendees / rrule are detail-only.
- */
-data class EventDetail(
- val instance: EventInstance,
- val description: String?,
- val organizer: String?,
- val attendees: List,
- val rrule: String?,
-)
-
-data class Attendee(
- val name: String,
- val email: String?,
- val status: AttendeeStatus,
-)
-
-enum class AttendeeStatus {
- Accepted,
- Declined,
- Tentative,
- NeedsAction,
- Unknown,
-}
-
-/**
- * Why a screen ended up in Failure state. Each screen interprets these into a
- * concrete recovery action (re-request permission, open system settings, retry).
- */
-enum class FailureReason {
- PermissionRevoked,
- NoCalendarsConfigured,
- ProviderUnavailable,
- EventNotFound,
- Unknown,
-}
-```
-
-- [ ] **Step 4: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.domain.ModelsTest'
-```
-
-Expected: PASS, 5 tests green.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt
-git commit -m "domain: add pure-Kotlin models (CalendarSource, EventInstance, EventDetail, …)"
-```
-
----
-
-## Task 4: CalendarContract Projections
-
-The exact column names + projection-index constants the rest of `data/calendar/` relies on. No tests — projections are constants.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt`
-
-- [ ] **Step 1: Create `Projections.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.provider.CalendarContract
-
-/**
- * Frozen column projections + their stable indices, indexed-array style.
- *
- * Rule: the projection array and the *_IDX constants must move together. If
- * you reorder one, reorder the other.
- */
-
-internal object CalendarProjection {
- val COLUMNS: Array = arrayOf(
- CalendarContract.Calendars._ID, // 0
- CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, // 1
- CalendarContract.Calendars.ACCOUNT_NAME, // 2
- CalendarContract.Calendars.ACCOUNT_TYPE, // 3
- CalendarContract.Calendars.CALENDAR_COLOR, // 4
- CalendarContract.Calendars.VISIBLE, // 5
- )
-
- const val IDX_ID = 0
- const val IDX_DISPLAY_NAME = 1
- const val IDX_ACCOUNT_NAME = 2
- const val IDX_ACCOUNT_TYPE = 3
- const val IDX_COLOR = 4
- const val IDX_VISIBLE = 5
-}
-
-internal object InstanceProjection {
- val COLUMNS: Array = arrayOf(
- CalendarContract.Instances._ID, // 0
- CalendarContract.Instances.EVENT_ID, // 1
- CalendarContract.Instances.CALENDAR_ID, // 2
- CalendarContract.Instances.TITLE, // 3
- CalendarContract.Instances.BEGIN, // 4
- CalendarContract.Instances.END, // 5
- CalendarContract.Instances.ALL_DAY, // 6
- CalendarContract.Instances.EVENT_COLOR, // 7
- CalendarContract.Instances.CALENDAR_COLOR,// 8
- CalendarContract.Instances.EVENT_LOCATION,// 9
- )
-
- const val IDX_INSTANCE_ID = 0
- const val IDX_EVENT_ID = 1
- const val IDX_CALENDAR_ID = 2
- const val IDX_TITLE = 3
- const val IDX_BEGIN = 4
- const val IDX_END = 5
- const val IDX_ALL_DAY = 6
- const val IDX_EVENT_COLOR = 7
- const val IDX_CALENDAR_COLOR = 8
- const val IDX_LOCATION = 9
-}
-
-internal object EventDetailProjection {
- val COLUMNS: Array = arrayOf(
- CalendarContract.Events._ID, // 0
- CalendarContract.Events.TITLE, // 1
- CalendarContract.Events.DESCRIPTION, // 2
- CalendarContract.Events.ORGANIZER, // 3
- CalendarContract.Events.RRULE, // 4
- CalendarContract.Events.EVENT_COLOR, // 5
- CalendarContract.Events.CALENDAR_COLOR, // 6
- CalendarContract.Events.DTSTART, // 7
- CalendarContract.Events.DTEND, // 8
- CalendarContract.Events.ALL_DAY, // 9
- CalendarContract.Events.EVENT_LOCATION, // 10
- CalendarContract.Events.CALENDAR_ID, // 11
- )
-
- const val IDX_EVENT_ID = 0
- const val IDX_TITLE = 1
- const val IDX_DESCRIPTION = 2
- const val IDX_ORGANIZER = 3
- const val IDX_RRULE = 4
- const val IDX_EVENT_COLOR = 5
- const val IDX_CALENDAR_COLOR = 6
- const val IDX_DTSTART = 7
- const val IDX_DTEND = 8
- const val IDX_ALL_DAY = 9
- const val IDX_LOCATION = 10
- const val IDX_CALENDAR_ID = 11
-}
-
-internal object AttendeeProjection {
- val COLUMNS: Array = arrayOf(
- CalendarContract.Attendees.ATTENDEE_NAME, // 0
- CalendarContract.Attendees.ATTENDEE_EMAIL, // 1
- CalendarContract.Attendees.ATTENDEE_STATUS, // 2
- )
-
- const val IDX_NAME = 0
- const val IDX_EMAIL = 1
- const val IDX_STATUS = 2
-}
-
-/**
- * Fallback labels for null/empty fields. Localized strings would couple
- * data layer to UI resources — keep these in-language hardcoded; the UI
- * never displays them to a human-facing label without going through ui/
- * (which can choose to override them via stringResource if it wants).
- *
- * For V1 we leave them as-is; users see them only in the Debug screen which
- * is wegwerfbar anyway.
- */
-internal object Fallbacks {
- const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
- const val UNTITLED_EVENT = "(Ohne Titel)"
-}
-```
-
-- [ ] **Step 2: Compile-check**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
-git commit -m "data: add CalendarContract column projections + indices"
-```
-
----
-
-## Task 5: Calendar Cursor Mapper
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt`
-
-- [ ] **Step 1: Write the failing test**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.MatrixCursor
-import com.google.common.truth.Truth.assertThat
-import org.junit.jupiter.api.Test
-
-class CalendarMapperTest {
-
- private fun cursorOf(vararg rows: Array): MatrixCursor {
- val c = MatrixCursor(CalendarProjection.COLUMNS)
- rows.forEach { c.addRow(it) }
- return c
- }
-
- @Test
- fun `happy path maps all six columns`() {
- val cur = cursorOf(
- arrayOf(42L, "Work", "x@y", "com.google", 0xFF112233.toInt(), 1)
- )
- cur.moveToFirst()
- val src = cur.toCalendarSource()
- assertThat(src).isEqualTo(
- de.jeanlucmakiola.calendula.domain.CalendarSource(
- id = 42L,
- displayName = "Work",
- accountName = "x@y",
- accountType = "com.google",
- color = 0xFF112233.toInt(),
- isVisibleInSystem = true,
- )
- )
- }
-
- @Test
- fun `null displayName falls back to placeholder`() {
- val cur = cursorOf(
- arrayOf(7L, null, "x@y", "LOCAL", 0xFF000000.toInt(), 1)
- )
- cur.moveToFirst()
- val src = cur.toCalendarSource()
- assertThat(src!!.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
- }
-
- @Test
- fun `visible flag 0 maps to false`() {
- val cur = cursorOf(
- arrayOf(1L, "Hidden", "x@y", "LOCAL", 0, 0)
- )
- cur.moveToFirst()
- assertThat(cur.toCalendarSource()!!.isVisibleInSystem).isFalse()
- }
-
- @Test
- fun `empty cursor returns null when called without moveToFirst`() {
- val cur = cursorOf()
- // No moveToFirst → cursor before first; mapper should not crash, return null
- assertThat(cur.toCalendarSource()).isNull()
- }
-}
-```
-
-- [ ] **Step 2: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.CalendarMapperTest'
-```
-
-Expected: FAIL with `Unresolved reference: toCalendarSource`.
-
-- [ ] **Step 3: Implement `CalendarMapper.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.Cursor
-import de.jeanlucmakiola.calendula.domain.CalendarSource
-
-/**
- * Maps the cursor's CURRENT row to a CalendarSource. The caller is responsible
- * for cursor positioning. Returns null when the cursor is not positioned on a
- * valid row.
- *
- * Defensive fallback: null displayName → "(Unbenannter Kalender)".
- */
-internal fun Cursor.toCalendarSource(): CalendarSource? {
- if (isBeforeFirst || isAfterLast) return null
- return CalendarSource(
- id = getLong(CalendarProjection.IDX_ID),
- displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
- ?: Fallbacks.UNNAMED_CALENDAR,
- accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
- accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
- color = getInt(CalendarProjection.IDX_COLOR),
- isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
- )
-}
-```
-
-- [ ] **Step 4: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.CalendarMapperTest'
-```
-
-Expected: PASS, 4 tests green.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt
-git commit -m "data: add Cursor.toCalendarSource() mapper with defensive fallback"
-```
-
----
-
-## Task 6: Instance Cursor Mapper (with defensive validation per Spec §8)
-
-Every defensive case from Spec §8 has its own test.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt`
-
-- [ ] **Step 1: Write the failing test**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.MatrixCursor
-import com.google.common.truth.Truth.assertThat
-import kotlinx.datetime.Instant
-import org.junit.jupiter.api.Test
-
-class InstanceMapperTest {
-
- private fun cursorOf(vararg rows: Array): MatrixCursor {
- val c = MatrixCursor(InstanceProjection.COLUMNS)
- rows.forEach { c.addRow(it) }
- return c
- }
-
- /**
- * Convenience: returns a row matching InstanceProjection.COLUMNS with all
- * non-required fields defaulted to known-good values. Callers override only
- * the field they care about.
- */
- private fun row(
- instanceId: Long = 10L,
- eventId: Long = 1L,
- calendarId: Long = 1L,
- title: String? = "Meet",
- begin: Long = 1_000_000_000L,
- end: Long = 1_000_003_600L,
- allDay: Int = 0,
- eventColor: Any? = null, // null OR Int
- calendarColor: Int = 0xFFAABBCC.toInt(),
- location: String? = null,
- ): Array = arrayOf(
- instanceId, eventId, calendarId, title, begin, end, allDay,
- eventColor, calendarColor, location,
- )
-
- @Test
- fun `happy path - non-allday event`() {
- val cur = cursorOf(row())
- cur.moveToFirst()
- val inst = cur.toEventInstance()
- assertThat(inst).isNotNull()
- assertThat(inst!!.title).isEqualTo("Meet")
- assertThat(inst.isAllDay).isFalse()
- assertThat(inst.start).isEqualTo(Instant.fromEpochMilliseconds(1_000_000_000L))
- assertThat(inst.end).isEqualTo(Instant.fromEpochMilliseconds(1_000_003_600L))
- }
-
- @Test
- fun `event color falls back to calendar color when null`() {
- val cur = cursorOf(row(eventColor = null, calendarColor = 0xFF112233.toInt()))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()!!.color).isEqualTo(0xFF112233.toInt())
- }
-
- @Test
- fun `event color wins over calendar color when present`() {
- val cur = cursorOf(
- row(eventColor = 0xFFDEADBE.toInt(), calendarColor = 0xFF112233.toInt())
- )
- cur.moveToFirst()
- assertThat(cur.toEventInstance()!!.color).isEqualTo(0xFFDEADBE.toInt())
- }
-
- @Test
- fun `null title falls back to placeholder`() {
- val cur = cursorOf(row(title = null))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
- }
-
- @Test
- fun `empty title falls back to placeholder`() {
- val cur = cursorOf(row(title = ""))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
- }
-
- @Test
- fun `dtend before dtstart drops the row`() {
- val cur = cursorOf(row(begin = 2000L, end = 1000L))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()).isNull()
- }
-
- @Test
- fun `dtstart before unix epoch drops the row`() {
- val cur = cursorOf(row(begin = -1L, end = 1000L))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()).isNull()
- }
-
- @Test
- fun `all-day flag 1 maps to true`() {
- val cur = cursorOf(row(allDay = 1))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()!!.isAllDay).isTrue()
- }
-
- @Test
- fun `location passes through when present`() {
- val cur = cursorOf(row(location = "Berlin"))
- cur.moveToFirst()
- assertThat(cur.toEventInstance()!!.location).isEqualTo("Berlin")
- }
-}
-```
-
-- [ ] **Step 2: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.InstanceMapperTest'
-```
-
-Expected: FAIL with `Unresolved reference: toEventInstance`.
-
-- [ ] **Step 3: Implement `InstanceMapper.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.Cursor
-import android.util.Log
-import de.jeanlucmakiola.calendula.domain.EventInstance
-
-private const val TAG = "InstanceMapper"
-
-/**
- * Maps the cursor's CURRENT row to an EventInstance, or returns null if the
- * row fails defensive validation (per Spec §8). Callers are expected to
- * filterNotNull() on the resulting list.
- */
-internal fun Cursor.toEventInstance(): EventInstance? {
- if (isBeforeFirst || isAfterLast) return null
-
- val begin = getLong(InstanceProjection.IDX_BEGIN)
- val end = getLong(InstanceProjection.IDX_END)
-
- // Defensive: dtstart < epoch → drop
- if (begin < 0L) {
- Log.w(TAG, "Dropping row with negative begin=$begin")
- return null
- }
-
- // Defensive: dtend < dtstart → drop
- if (end < begin) {
- Log.w(TAG, "Dropping row with end=$end < begin=$begin")
- return null
- }
-
- val rawTitle = getString(InstanceProjection.IDX_TITLE)
- val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
-
- // Effective color: event color wins, else calendar color.
- val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
- getInt(InstanceProjection.IDX_CALENDAR_COLOR)
- } else {
- getInt(InstanceProjection.IDX_EVENT_COLOR)
- }
-
- return EventInstance(
- instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
- eventId = getLong(InstanceProjection.IDX_EVENT_ID),
- calendarId = getLong(InstanceProjection.IDX_CALENDAR_ID),
- title = title,
- start = begin.toKotlinInstantFromEpochMillis(),
- end = end.toKotlinInstantFromEpochMillis(),
- isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
- color = color,
- location = getString(InstanceProjection.IDX_LOCATION),
- )
-}
-```
-
-- [ ] **Step 4: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.InstanceMapperTest'
-```
-
-Expected: PASS, 9 tests green.
-
-`Log.w` calls inside `InstanceMapper` are no-ops in the JVM tests because Task 1 already set `testOptions.unitTests.isReturnDefaultValues = true`. If you skipped that change, revisit Task 1.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt
-git commit -m "data: add Cursor.toEventInstance() with defensive validation (§8)"
-```
-
----
-
-## Task 7: Event Detail + Attendee Mapper
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt`
-
-- [ ] **Step 1: Write the failing test**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.MatrixCursor
-import android.provider.CalendarContract
-import com.google.common.truth.Truth.assertThat
-import de.jeanlucmakiola.calendula.domain.AttendeeStatus
-import org.junit.jupiter.api.Test
-
-class EventDetailMapperTest {
-
- private fun detailCursor(
- eventId: Long = 1L,
- title: String? = "Meet",
- description: String? = "Body",
- organizer: String? = "x@y",
- rrule: String? = null,
- eventColor: Any? = null,
- calendarColor: Int = 0xFFAABBCC.toInt(),
- dtstart: Long = 1_000_000_000L,
- dtend: Long = 1_000_003_600L,
- allDay: Int = 0,
- location: String? = "Berlin",
- calendarId: Long = 7L,
- ): MatrixCursor {
- val c = MatrixCursor(EventDetailProjection.COLUMNS)
- c.addRow(
- arrayOf(
- eventId, title, description, organizer, rrule,
- eventColor, calendarColor, dtstart, dtend, allDay, location, calendarId,
- )
- )
- return c
- }
-
- private fun attendeeCursor(vararg attendees: Array): MatrixCursor {
- val c = MatrixCursor(AttendeeProjection.COLUMNS)
- attendees.forEach { c.addRow(it) }
- return c
- }
-
- @Test
- fun `happy path detail maps all fields and embeds matching EventInstance`() {
- val cur = detailCursor()
- cur.moveToFirst()
- val detail = cur.toEventDetailCore(attendees = emptyList())
- assertThat(detail).isNotNull()
- assertThat(detail!!.description).isEqualTo("Body")
- assertThat(detail.organizer).isEqualTo("x@y")
- assertThat(detail.instance.title).isEqualTo("Meet")
- assertThat(detail.instance.location).isEqualTo("Berlin")
- assertThat(detail.attendees).isEmpty()
- }
-
- @Test
- fun `event color falls back to calendar color when null`() {
- val cur = detailCursor(eventColor = null, calendarColor = 0xFF112233.toInt())
- cur.moveToFirst()
- val detail = cur.toEventDetailCore(attendees = emptyList())
- assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
- }
-
- @Test
- fun `dtend before dtstart drops detail`() {
- val cur = detailCursor(dtstart = 2000L, dtend = 1000L)
- cur.moveToFirst()
- assertThat(cur.toEventDetailCore(attendees = emptyList())).isNull()
- }
-
- @Test
- fun `rrule passes through when present`() {
- val cur = detailCursor(rrule = "FREQ=WEEKLY;BYDAY=MO")
- cur.moveToFirst()
- assertThat(cur.toEventDetailCore(attendees = emptyList())!!.rrule)
- .isEqualTo("FREQ=WEEKLY;BYDAY=MO")
- }
-
- @Test
- fun `attendee status mapping covers all five variants`() {
- val cur = attendeeCursor(
- arrayOf("Alice", "alice@x", CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED),
- arrayOf("Bob", "bob@x", CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED),
- arrayOf("Carol", "carol@x", CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE),
- arrayOf("Dave", "dave@x", CalendarContract.Attendees.ATTENDEE_STATUS_INVITED),
- arrayOf("Eve", "eve@x", 42), // Unknown int code
- arrayOf(null, null, CalendarContract.Attendees.ATTENDEE_STATUS_NONE),
- )
-
- val results = mutableListOf()
- cur.moveToFirst()
- do {
- results += cur.toAttendee().status
- } while (cur.moveToNext())
-
- assertThat(results).containsExactly(
- AttendeeStatus.Accepted,
- AttendeeStatus.Declined,
- AttendeeStatus.Tentative,
- AttendeeStatus.NeedsAction,
- AttendeeStatus.Unknown,
- AttendeeStatus.Unknown,
- ).inOrder()
- }
-
- @Test
- fun `attendee with null name maps to empty string`() {
- val cur = attendeeCursor(
- arrayOf(null, "alice@x", CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED),
- )
- cur.moveToFirst()
- assertThat(cur.toAttendee().name).isEqualTo("")
- }
-}
-```
-
-- [ ] **Step 2: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.EventDetailMapperTest'
-```
-
-Expected: FAIL with `Unresolved reference: toEventDetailCore` (or `toAttendee`).
-
-- [ ] **Step 3: Implement `EventDetailMapper.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.Cursor
-import android.provider.CalendarContract
-import android.util.Log
-import de.jeanlucmakiola.calendula.domain.Attendee
-import de.jeanlucmakiola.calendula.domain.AttendeeStatus
-import de.jeanlucmakiola.calendula.domain.EventDetail
-import de.jeanlucmakiola.calendula.domain.EventInstance
-
-private const val TAG = "EventDetailMapper"
-
-/**
- * Maps the cursor's CURRENT row (from an Events query using EventDetailProjection)
- * to an EventDetail. Attendees are loaded separately (CalendarContract requires
- * a different query) and passed in.
- *
- * Returns null when defensive validation fails (e.g. dtend < dtstart).
- */
-internal fun Cursor.toEventDetailCore(attendees: List): EventDetail? {
- if (isBeforeFirst || isAfterLast) return null
-
- val begin = getLong(EventDetailProjection.IDX_DTSTART)
- val end = getLong(EventDetailProjection.IDX_DTEND)
-
- if (begin < 0L) {
- Log.w(TAG, "Dropping event with negative dtstart=$begin")
- return null
- }
- if (end < begin) {
- Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
- return null
- }
-
- val rawTitle = getString(EventDetailProjection.IDX_TITLE)
- val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
-
- val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
- getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
- } else {
- getInt(EventDetailProjection.IDX_EVENT_COLOR)
- }
-
- val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
- val instance = EventInstance(
- instanceId = eventId, // For non-recurring detail, instance == event.
- eventId = eventId,
- calendarId = getLong(EventDetailProjection.IDX_CALENDAR_ID),
- title = title,
- start = begin.toKotlinInstantFromEpochMillis(),
- end = end.toKotlinInstantFromEpochMillis(),
- isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
- color = color,
- location = getString(EventDetailProjection.IDX_LOCATION),
- )
-
- return EventDetail(
- instance = instance,
- description = getString(EventDetailProjection.IDX_DESCRIPTION),
- organizer = getString(EventDetailProjection.IDX_ORGANIZER),
- attendees = attendees,
- rrule = getString(EventDetailProjection.IDX_RRULE),
- )
-}
-
-/**
- * Maps the cursor's CURRENT row (from an Attendees query using AttendeeProjection)
- * to an Attendee. Defensive: null name → "".
- */
-internal fun Cursor.toAttendee(): Attendee = Attendee(
- name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
- email = getString(AttendeeProjection.IDX_EMAIL),
- status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
-)
-
-private fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
- CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED -> AttendeeStatus.Accepted
- CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> AttendeeStatus.Declined
- CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE -> AttendeeStatus.Tentative
- CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
- else -> AttendeeStatus.Unknown
-}
-```
-
-- [ ] **Step 4: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.EventDetailMapperTest'
-```
-
-Expected: PASS, 6 tests green.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
-git commit -m "data: add Cursor.toEventDetailCore() and Cursor.toAttendee() mappers"
-```
-
----
-
-## Task 8: CalendarContentResolver Interface + Android Impl
-
-A thin wrapper to make the repository testable without a real `ContentResolver`.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarContentResolver.kt`
-
-- [ ] **Step 1: Create the interface and Android implementation**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.content.ContentResolver
-import android.content.ContentUris
-import android.content.Context
-import android.database.ContentObserver
-import android.database.Cursor
-import android.provider.CalendarContract
-import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Thin seam over Android's ContentResolver, so the repository can be unit-tested
- * with an in-memory fake.
- *
- * All query methods return a raw Cursor — the caller (CalendarRepositoryImpl) is
- * responsible for `use { … }` to close them and for iterating + mapping.
- */
-interface CalendarContentResolver {
- fun queryCalendars(): Cursor?
- fun queryInstances(beginMillis: Long, endMillis: Long): Cursor?
- fun queryEvent(eventId: Long): Cursor?
- fun queryAttendees(eventId: Long): Cursor?
- fun registerObserver(observer: ContentObserver)
- fun unregisterObserver(observer: ContentObserver)
-}
-
-@Singleton
-class AndroidCalendarContentResolver @Inject constructor(
- @ApplicationContext private val context: Context,
-) : CalendarContentResolver {
-
- private val resolver: ContentResolver get() = context.contentResolver
-
- override fun queryCalendars(): Cursor? = resolver.query(
- CalendarContract.Calendars.CONTENT_URI,
- CalendarProjection.COLUMNS,
- null, null,
- CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
- )
-
- override fun queryInstances(beginMillis: Long, endMillis: Long): Cursor? {
- val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
- ContentUris.appendId(this, beginMillis)
- ContentUris.appendId(this, endMillis)
- }.build()
- return resolver.query(
- uri,
- InstanceProjection.COLUMNS,
- null, null,
- CalendarContract.Instances.BEGIN + " ASC",
- )
- }
-
- override fun queryEvent(eventId: Long): Cursor? = resolver.query(
- ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
- EventDetailProjection.COLUMNS,
- null, null, null,
- )
-
- override fun queryAttendees(eventId: Long): Cursor? = resolver.query(
- CalendarContract.Attendees.CONTENT_URI,
- AttendeeProjection.COLUMNS,
- CalendarContract.Attendees.EVENT_ID + " = ?",
- arrayOf(eventId.toString()),
- null,
- )
-
- override fun registerObserver(observer: ContentObserver) {
- resolver.registerContentObserver(
- CalendarContract.CONTENT_URI,
- /* notifyForDescendants = */ true,
- observer,
- )
- }
-
- override fun unregisterObserver(observer: ContentObserver) {
- resolver.unregisterContentObserver(observer)
- }
-}
-```
-
-- [ ] **Step 2: Compile-check**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarContentResolver.kt
-git commit -m "data: add CalendarContentResolver seam over ContentResolver"
-```
-
----
-
-## Task 9: CalendarRepository Interface + Impl (with ContentObserver + SharedFlow)
-
-The heart of the data layer. Hilt-Singleton, holds one ContentObserver, re-emits whenever the provider notifies a change.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt`
-- Create: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarContentResolver.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt`
-
-- [ ] **Step 1: Create the `@IoDispatcher` qualifier**
-
-`app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.di
-
-import javax.inject.Qualifier
-
-/**
- * Marks the IO-bound CoroutineDispatcher (Dispatchers.IO) for Hilt injection,
- * separate from any default/Main dispatchers we may bind later.
- */
-@Qualifier
-@Retention(AnnotationRetention.BINARY)
-annotation class IoDispatcher
-```
-
-- [ ] **Step 2: Define the repository interface**
-
-`app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import de.jeanlucmakiola.calendula.domain.CalendarSource
-import de.jeanlucmakiola.calendula.domain.EventDetail
-import de.jeanlucmakiola.calendula.domain.EventInstance
-import kotlinx.coroutines.flow.Flow
-import kotlinx.datetime.Instant
-
-/**
- * The single read-side surface against CalendarContract. Implementations
- * are responsible for ContentObserver wiring and re-emitting fresh data.
- *
- * Methods return Flow so consumers automatically pick up external changes
- * (DAVx5 sync, user edits in Google Calendar, …) via the observer.
- */
-interface CalendarRepository {
-
- /**
- * All configured calendar sources, sorted by display name ascending.
- * Emits a new value after every provider change.
- */
- fun calendars(): Flow>
-
- /**
- * All event instances overlapping the given inclusive instant range,
- * sorted by start ascending. Recurrence expansion is done by the provider.
- */
- fun instances(range: ClosedRange): Flow>
-
- /**
- * One-shot lookup of an event's read-only detail (including attendees).
- * Throws NoSuchEventException if the event id is gone or invalid.
- */
- suspend fun eventDetail(eventId: Long): EventDetail
-}
-
-class NoSuchEventException(eventId: Long) :
- NoSuchElementException("No event with id=$eventId")
-```
-
-- [ ] **Step 3: Implement the repository**
-
-`app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.ContentObserver
-import android.os.Handler
-import android.os.Looper
-import de.jeanlucmakiola.calendula.data.di.IoDispatcher
-import de.jeanlucmakiola.calendula.domain.Attendee
-import de.jeanlucmakiola.calendula.domain.CalendarSource
-import de.jeanlucmakiola.calendula.domain.EventDetail
-import de.jeanlucmakiola.calendula.domain.EventInstance
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.onStart
-import kotlinx.coroutines.withContext
-import kotlinx.datetime.Instant
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Repository impl. One ContentObserver lives for the lifetime of the
- * Application process (we never unregister — the receiver is also process-bound).
- *
- * Public flows: re-query on subscription, then re-query whenever the observer
- * fires a tick. Tick stream is a Buffered SharedFlow so multiple concurrent
- * subscribers all see the same update.
- */
-@Singleton
-class CalendarRepositoryImpl @Inject constructor(
- private val resolver: CalendarContentResolver,
- @IoDispatcher private val io: CoroutineDispatcher,
-) : CalendarRepository {
-
- private val ticks = MutableSharedFlow(
- replay = 0,
- extraBufferCapacity = 1,
- )
-
- private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
- override fun onChange(selfChange: Boolean) {
- ticks.tryEmit(Unit)
- }
- }
-
- init {
- resolver.registerObserver(observer)
- }
-
- override fun calendars(): Flow> =
- ticks
- .onStart { emit(Unit) }
- .mapLatestList { queryCalendars() }
- .flowOn(io)
-
- override fun instances(range: ClosedRange): Flow> =
- ticks
- .onStart { emit(Unit) }
- .mapLatestList {
- queryInstances(
- beginMillis = range.start.toEpochMillis(),
- endMillis = range.endInclusive.toEpochMillis(),
- )
- }
- .flowOn(io)
-
- override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
- val attendees = readAttendees(eventId)
- val cursor = resolver.queryEvent(eventId)
- ?: throw NoSuchEventException(eventId)
- cursor.use {
- if (!it.moveToFirst()) throw NoSuchEventException(eventId)
- it.toEventDetailCore(attendees)
- ?: throw NoSuchEventException(eventId)
- }
- }
-
- private fun queryCalendars(): List =
- resolver.queryCalendars()?.use { c ->
- val out = mutableListOf()
- while (c.moveToNext()) c.toCalendarSource()?.let(out::add)
- out
- } ?: emptyList()
-
- private fun queryInstances(beginMillis: Long, endMillis: Long): List =
- resolver.queryInstances(beginMillis, endMillis)?.use { c ->
- val out = mutableListOf()
- while (c.moveToNext()) c.toEventInstance()?.let(out::add)
- out
- } ?: emptyList()
-
- private fun readAttendees(eventId: Long): List =
- resolver.queryAttendees(eventId)?.use { c ->
- val out = mutableListOf()
- while (c.moveToNext()) out += c.toAttendee()
- out
- } ?: emptyList()
-}
-
-/**
- * Helper: re-runs `block` on every emission of the upstream Flow.
- * Equivalent to flatMapLatest { flow { emit(block()) } } but simpler.
- */
-private fun Flow.mapLatestList(block: suspend () -> List): Flow> =
- flow {
- collect { emit(block()) }
- }
-```
-
-- [ ] **Step 4: Create the FakeCalendarContentResolver helper for tests**
-
-`app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarContentResolver.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.ContentObserver
-import android.database.Cursor
-import android.database.MatrixCursor
-
-/**
- * Test-only fake. Allows you to seed in-memory Matrix cursors per query type,
- * and to "tick" the observer manually to simulate provider changes.
- */
-internal class FakeCalendarContentResolver : CalendarContentResolver {
-
- var calendarsCursorFactory: () -> Cursor? = { MatrixCursor(CalendarProjection.COLUMNS) }
- var instancesCursorFactory: (Long, Long) -> Cursor? = { _, _ ->
- MatrixCursor(InstanceProjection.COLUMNS)
- }
- var eventCursorFactory: (Long) -> Cursor? = { MatrixCursor(EventDetailProjection.COLUMNS) }
- var attendeesCursorFactory: (Long) -> Cursor? = { MatrixCursor(AttendeeProjection.COLUMNS) }
-
- private val observers = mutableListOf()
-
- override fun queryCalendars(): Cursor? = calendarsCursorFactory()
- override fun queryInstances(beginMillis: Long, endMillis: Long): Cursor? =
- instancesCursorFactory(beginMillis, endMillis)
- override fun queryEvent(eventId: Long): Cursor? = eventCursorFactory(eventId)
- override fun queryAttendees(eventId: Long): Cursor? = attendeesCursorFactory(eventId)
-
- override fun registerObserver(observer: ContentObserver) {
- observers += observer
- }
-
- override fun unregisterObserver(observer: ContentObserver) {
- observers -= observer
- }
-
- /** Manually trigger every registered observer (simulates a provider change). */
- fun tick() {
- observers.forEach { it.onChange(/* selfChange = */ false) }
- }
-}
-```
-
-- [ ] **Step 5: Write the failing repository test**
-
-`app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.database.MatrixCursor
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runTest
-import kotlinx.datetime.Instant
-import org.junit.jupiter.api.Test
-
-class CalendarRepositoryImplTest {
-
- private fun calCursor(vararg rows: Array): MatrixCursor {
- val c = MatrixCursor(CalendarProjection.COLUMNS)
- rows.forEach { c.addRow(it) }
- return c
- }
-
- private fun instCursor(vararg rows: Array): MatrixCursor {
- val c = MatrixCursor(InstanceProjection.COLUMNS)
- rows.forEach { c.addRow(it) }
- return c
- }
-
- private fun calRow(id: Long, name: String) =
- arrayOf(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), 1)
-
- private fun instRow(
- instanceId: Long,
- title: String,
- begin: Long = 1_000_000_000L,
- end: Long = 1_000_003_600L,
- ) = arrayOf(
- instanceId, instanceId, /* calendarId = */ 1L, title,
- begin, end, 0, null, 0xFFAABBCC.toInt(), null,
- )
-
- @Test
- fun `calendars emits initial query result on subscribe`() = runTest {
- val fake = FakeCalendarContentResolver().apply {
- calendarsCursorFactory = { calCursor(calRow(1L, "A"), calRow(2L, "B")) }
- }
- val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
-
- repo.calendars().test {
- val first = awaitItem()
- assertThat(first.map { it.id }).containsExactly(1L, 2L).inOrder()
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `calendars re-emits after observer tick`() = runTest {
- var rows = listOf(calRow(1L, "A"))
- val fake = FakeCalendarContentResolver().apply {
- calendarsCursorFactory = { calCursor(*rows.toTypedArray()) }
- }
- val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
-
- repo.calendars().test {
- assertThat(awaitItem().map { it.id }).containsExactly(1L)
-
- rows = listOf(calRow(1L, "A"), calRow(2L, "B"))
- fake.tick()
-
- assertThat(awaitItem().map { it.id }).containsExactly(1L, 2L).inOrder()
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `instances queries with correct epoch-millis bounds`() = runTest {
- var observedBegin: Long? = null
- var observedEnd: Long? = null
- val fake = FakeCalendarContentResolver().apply {
- instancesCursorFactory = { b, e ->
- observedBegin = b
- observedEnd = e
- instCursor(instRow(10L, "X"))
- }
- }
- val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
-
- val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
- repo.instances(range).test {
- awaitItem()
- cancelAndIgnoreRemainingEvents()
- }
- assertThat(observedBegin).isEqualTo(1_000L)
- assertThat(observedEnd).isEqualTo(2_000L)
- }
-
- @Test
- fun `instances drops rows that fail defensive validation`() = runTest {
- val fake = FakeCalendarContentResolver().apply {
- instancesCursorFactory = { _, _ ->
- instCursor(
- instRow(10L, "Good"),
- instRow(11L, "BadOrder", begin = 5000L, end = 1000L),
- )
- }
- }
- val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
-
- val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
- repo.instances(range).test {
- val first = awaitItem()
- assertThat(first.map { it.title }).containsExactly("Good")
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `eventDetail throws when cursor is empty`() = runTest {
- val fake = FakeCalendarContentResolver().apply {
- eventCursorFactory = { MatrixCursor(EventDetailProjection.COLUMNS) }
- }
- val repo = CalendarRepositoryImpl(fake, Dispatchers.Unconfined)
-
- try {
- repo.eventDetail(eventId = 999L)
- error("Expected NoSuchEventException")
- } catch (expected: NoSuchEventException) {
- assertThat(expected.message).contains("999")
- }
- }
-}
-```
-
-- [ ] **Step 6: Run test - confirm it fails (it depends on Steps 2-3 actually existing)**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImplTest'
-```
-
-Expected: PASS if Steps 2-3 (interface + impl) were saved correctly. If you see `Unresolved reference: CalendarRepositoryImpl` or similar, recheck that the files in Steps 2-3 were written. The test asserts behavior that the impl already provides.
-
-If the test legitimately fails (e.g. Turbine timeout), recheck `mapLatestList` for a bug; the correct shape is the helper in CalendarRepositoryImpl.kt that re-collects.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt \
- app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt \
- app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarContentResolver.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
-git commit -m "data: add CalendarRepository + Impl with ContentObserver-backed SharedFlow"
-```
-
----
-
-## Task 10: DataStore — hiddenCalendarIds
-
-DataStore for the app-side "user has hidden these calendars" preference. Stored as a comma-separated string (DataStore Preferences has no `Set` primitive).
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt`
-
-- [ ] **Step 1: Write the failing test**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.prefs
-
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.PreferenceDataStoreFactory
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.io.TempDir
-import java.nio.file.Path
-
-class CalendarPrefsTest {
-
- private fun newDataStore(tempDir: Path): DataStore {
- return PreferenceDataStoreFactory.create(
- produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
- )
- }
-
- @Test
- fun `hiddenCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
- val prefs = CalendarPrefs(newDataStore(tempDir))
- assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
- }
-
- @Test
- fun `setHiddenCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
- val store = newDataStore(tempDir)
- val prefs = CalendarPrefs(store)
- prefs.setHiddenCalendarIds(setOf(1L, 42L, 7L))
- assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
- }
-
- @Test
- fun `setting empty set clears storage`(@TempDir tempDir: Path) = runTest {
- val prefs = CalendarPrefs(newDataStore(tempDir))
- prefs.setHiddenCalendarIds(setOf(1L))
- prefs.setHiddenCalendarIds(emptySet())
- assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
- }
-
- @Test
- fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
- // Older builds (or a corrupted prefs file) might leave invalid tokens.
- // The flow should defensively skip them.
- val store = newDataStore(tempDir)
- val prefs = CalendarPrefs(store)
- // Use the public API to write a "good" set then directly inject a tampered key
- prefs.setHiddenCalendarIds(setOf(1L, 2L))
- // Simulate corruption by writing a manual value with bad tokens
- store.updateData { p ->
- val mutable = p.toMutablePreferences()
- mutable[CalendarPrefs.HIDDEN_IDS_KEY] = "1,abc,3"
- mutable
- }
- assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
- }
-}
-```
-
-- [ ] **Step 2: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.prefs.CalendarPrefsTest'
-```
-
-Expected: FAIL with `Unresolved reference: CalendarPrefs`.
-
-- [ ] **Step 3: Implement `CalendarPrefs.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.prefs
-
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.stringPreferencesKey
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * App-side preference for "calendars the user has hidden in this app",
- * separate from the system's per-calendar VISIBLE flag.
- *
- * Persisted as a comma-separated string of Long ids; non-numeric tokens are
- * silently skipped (defensive — see CalendarPrefsTest).
- */
-@Singleton
-class CalendarPrefs @Inject constructor(
- private val store: DataStore,
-) {
-
- val hiddenCalendarIds: Flow> = store.data.map { prefs ->
- prefs[HIDDEN_IDS_KEY].orEmpty()
- .split(',')
- .mapNotNull { it.trim().toLongOrNull() }
- .toSet()
- }
-
- suspend fun setHiddenCalendarIds(ids: Set) {
- store.edit { prefs ->
- if (ids.isEmpty()) {
- prefs.remove(HIDDEN_IDS_KEY)
- } else {
- prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
- }
- }
- }
-
- companion object {
- internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
- }
-}
-```
-
-- [ ] **Step 4: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.data.prefs.CalendarPrefsTest'
-```
-
-Expected: PASS, 4 tests green.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt
-git commit -m "data: add CalendarPrefs (hidden calendar ids in DataStore)"
-```
-
----
-
-## Task 11: Hilt Data Module
-
-Bind interfaces to implementations + provide the DataStore singleton + IO dispatcher.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt`
-
-- [ ] **Step 1: Create `DataModule.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.di
-
-import android.content.Context
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.preferencesDataStore
-import dagger.Binds
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarContentResolver
-import de.jeanlucmakiola.calendula.data.calendar.CalendarContentResolver
-import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
-import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-import javax.inject.Singleton
-
-private val Context.calendulaDataStore: DataStore by preferencesDataStore(
- name = "calendula_prefs",
-)
-
-@Module
-@InstallIn(SingletonComponent::class)
-abstract class DataBindModule {
-
- @Binds
- @Singleton
- abstract fun bindCalendarContentResolver(
- impl: AndroidCalendarContentResolver,
- ): CalendarContentResolver
-
- @Binds
- @Singleton
- abstract fun bindCalendarRepository(
- impl: CalendarRepositoryImpl,
- ): CalendarRepository
-}
-
-@Module
-@InstallIn(SingletonComponent::class)
-object DataProvideModule {
-
- @Provides
- @Singleton
- fun provideDataStore(@ApplicationContext context: Context): DataStore =
- context.calendulaDataStore
-
- @Provides
- @IoDispatcher
- fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
-}
-```
-
-- [ ] **Step 2: Compile-check**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL. KSP runs Hilt and generates the singleton component.
-
-If you see `Hilt: ... requires @Singleton` or a similar Hilt error, double-check that `CalendarRepositoryImpl` and `AndroidCalendarContentResolver` are annotated `@Singleton` (Tasks 8 and 9).
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt
-git commit -m "di: wire CalendarRepository, ContentResolver, DataStore, IoDispatcher"
-```
-
----
-
-## Task 12: i18n Strings for Permission + Debug
-
-Append the new keys to both `values/strings.xml` and `values-de/strings.xml`. Keep ordering consistent across both files.
-
-**Files:**
-- Modify: `app/src/main/res/values/strings.xml`
-- Modify: `app/src/main/res/values-de/strings.xml`
-
-- [ ] **Step 1: Append new keys to `values/strings.xml`**
-
-The full file should now read:
-
-```xml
-
- Calendula
- A modern calendar.
-
-
- Loading…
- Retry
- Something went wrong.
- Calendar access is required.
- Grant access
- No calendars configured.
- Open system calendar settings
- Could not read the calendar.
-
-
- Calendar access
- Calendula reads only your device calendar — no data leaves your device.
- Continue
- Calendar access denied
- Calendula cannot show events without calendar access. You can grant it again in the system settings.
- Open system settings
- Try again
-
-
- DEBUG — replaced by month view in Plan 03
- Calendars
- Next 50 events
- No calendars configured. Add one via DAVx5 or system settings.
- No upcoming events in the next 30 days.
-
-```
-
-- [ ] **Step 2: Append new keys to `values-de/strings.xml`**
-
-The full German file:
-
-```xml
-
- Calendula
- Ein moderner Kalender.
-
- Lädt…
- Erneut versuchen
- Etwas ist schiefgelaufen.
- Zugriff auf den Kalender wird benötigt.
- Zugriff erlauben
- Keine Kalender eingerichtet.
- System-Kalender-Einstellungen öffnen
- Kalender konnte nicht gelesen werden.
-
-
- Kalender-Zugriff
- Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.
- Weiter
- Kalender-Zugriff abgelehnt
- Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.
- System-Einstellungen öffnen
- Erneut versuchen
-
-
- DEBUG — wird mit Plan 03 durch die Monatsansicht ersetzt
- Kalender
- Nächste 50 Termine
- Keine Kalender eingerichtet. Füge einen über DAVx5 oder die System-Einstellungen hinzu.
- Keine anstehenden Termine in den nächsten 30 Tagen.
-
-```
-
-- [ ] **Step 3: Compile-check (verify lint sees both locales as complete)**
-
-```bash
-./gradlew :app:lintDebug
-```
-
-Expected: BUILD SUCCESSFUL. If lint complains about missing translations, the two files have drifted — re-verify every `` appears in both files.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add app/src/main/res/values/strings.xml app/src/main/res/values-de/strings.xml
-git commit -m "i18n: add permission + debug screen strings (en, de)"
-```
-
----
-
-## Task 13: PermissionUiState + ViewModel
-
-`PermissionViewModel` exposes a small state that the UI dispatches on. Permission-status checks against the system happen through helper methods called by the activity (the ViewModel is otherwise context-free).
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionUiState.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionViewModel.kt`
-- Test: tests for the state machine happen via UI test in Task 14 (PermissionScreenTest)
-
-- [ ] **Step 1: Define the UI state**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.permission
-
-/**
- * Permission flow state. The screen lives in one of these three at any time.
- *
- * - Rationale: first-time prompt; user has not yet seen the system dialog
- * - Denied: user said no — show recovery (retry or open settings)
- * - Granted: terminal — screen calls onGranted and unmounts
- */
-sealed interface PermissionUiState {
- data object Rationale : PermissionUiState
- data object Denied : PermissionUiState
- data object Granted : PermissionUiState
-}
-```
-
-- [ ] **Step 2: Implement the ViewModel**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.permission
-
-import androidx.lifecycle.ViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import javax.inject.Inject
-
-@HiltViewModel
-class PermissionViewModel @Inject constructor() : ViewModel() {
-
- private val _state = MutableStateFlow(PermissionUiState.Rationale)
- val state: StateFlow = _state.asStateFlow()
-
- fun onGranted() {
- _state.value = PermissionUiState.Granted
- }
-
- fun onDenied() {
- _state.value = PermissionUiState.Denied
- }
-
- fun onRetry() {
- // Move back to Rationale → the screen will trigger the system dialog again
- _state.value = PermissionUiState.Rationale
- }
-}
-```
-
-- [ ] **Step 3: Compile-check**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionUiState.kt \
- app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionViewModel.kt
-git commit -m "ui: add PermissionViewModel with three-state machine"
-```
-
----
-
-## Task 14: Permission Screen Composable
-
-The first screen the user sees when permission is not granted. Hands off `onGranted` upward when permission is acquired.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt`
-- Test: `app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt`
-
-- [ ] **Step 1: Implement `PermissionScreen.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.permission
-
-import android.Manifest
-import android.content.Intent
-import android.net.Uri
-import android.provider.Settings
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import de.jeanlucmakiola.calendula.R
-
-@Composable
-fun PermissionScreen(
- onGranted: () -> Unit,
- modifier: Modifier = Modifier,
- viewModel: PermissionViewModel = hiltViewModel(),
-) {
- val state by viewModel.state.collectAsStateWithLifecycle()
-
- val launcher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.RequestPermission(),
- ) { granted ->
- if (granted) viewModel.onGranted() else viewModel.onDenied()
- }
-
- LaunchedEffect(state) {
- if (state == PermissionUiState.Granted) onGranted()
- }
-
- when (state) {
- is PermissionUiState.Rationale -> RationaleContent(
- onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
- modifier = modifier,
- )
- is PermissionUiState.Denied -> DeniedContent(
- onRetry = {
- viewModel.onRetry()
- launcher.launch(Manifest.permission.READ_CALENDAR)
- },
- modifier = modifier,
- )
- is PermissionUiState.Granted -> {
- // Transient — the LaunchedEffect above fires and the parent
- // composable will replace us. Render nothing.
- }
- }
-}
-
-@Composable
-private fun RationaleContent(
- onRequest: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Column(
- modifier = modifier.fillMaxSize().padding(24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- text = stringResource(R.string.permission_rationale_title),
- style = MaterialTheme.typography.headlineMedium,
- )
- Spacer(Modifier.height(16.dp))
- Text(
- text = stringResource(R.string.permission_rationale_body),
- style = MaterialTheme.typography.bodyLarge,
- )
- Spacer(Modifier.height(32.dp))
- Button(onClick = onRequest) {
- Text(stringResource(R.string.permission_request_button))
- }
- }
-}
-
-@Composable
-private fun DeniedContent(
- onRetry: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val context = LocalContext.current
- Column(
- modifier = modifier.fillMaxSize().padding(24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- text = stringResource(R.string.permission_denied_title),
- style = MaterialTheme.typography.headlineMedium,
- )
- Spacer(Modifier.height(16.dp))
- Text(
- text = stringResource(R.string.permission_denied_body),
- style = MaterialTheme.typography.bodyLarge,
- )
- Spacer(Modifier.height(32.dp))
- Button(onClick = onRetry) {
- Text(stringResource(R.string.permission_retry_button))
- }
- Spacer(Modifier.height(12.dp))
- OutlinedButton(
- onClick = {
- val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
- data = Uri.fromParts("package", context.packageName, null)
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
- },
- ) {
- Text(stringResource(R.string.permission_open_settings_button))
- }
- }
-}
-```
-
-- [ ] **Step 2: Write the instrumented test**
-
-`app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.permission
-
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import de.jeanlucmakiola.calendula.R
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class PermissionScreenTest {
-
- @get:Rule
- val composeTestRule = createComposeRule()
-
- private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
-
- @Test
- fun rationale_renders_title_and_button() {
- composeTestRule.setContent {
- PermissionScreen(onGranted = {})
- }
- composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
- .assertIsDisplayed()
- composeTestRule.onNodeWithText(res.getString(R.string.permission_request_button))
- .assertIsDisplayed()
- }
-}
-```
-
-- [ ] **Step 3: Run the test on an emulator**
-
-```bash
-./gradlew :app:connectedDebugAndroidTest --tests 'de.jeanlucmakiola.calendula.ui.permission.PermissionScreenTest'
-```
-
-Expected: PASS, 1 test green. Skip this step if no emulator is connected locally; CI will run it.
-
-- [ ] **Step 4: Compile-check the main source**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt \
- app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreenTest.kt
-git commit -m "ui: add PermissionScreen with rationale and denied recovery"
-```
-
----
-
-## Task 15: Debug ViewModel + State
-
-The Debug ViewModel combines `repository.calendars()` and `repository.instances(today..today+30d)` into one state.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt`
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt`
-- Test: `app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt`
-
-- [ ] **Step 1: Define the UI state**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.debug
-
-import de.jeanlucmakiola.calendula.domain.CalendarSource
-import de.jeanlucmakiola.calendula.domain.EventInstance
-import de.jeanlucmakiola.calendula.domain.FailureReason
-
-sealed interface DebugUiState {
- data object Loading : DebugUiState
- data class Failure(val reason: FailureReason) : DebugUiState
- data class Success(
- val calendars: List,
- val nextEvents: List,
- ) : DebugUiState
-}
-```
-
-- [ ] **Step 2: Write the failing test**
-
-`app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt`:
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.debug
-
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
-import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
-import de.jeanlucmakiola.calendula.domain.CalendarSource
-import de.jeanlucmakiola.calendula.domain.EventDetail
-import de.jeanlucmakiola.calendula.domain.EventInstance
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runTest
-import kotlinx.datetime.Instant
-import org.junit.jupiter.api.Test
-
-class DebugViewModelTest {
-
- private class FakeRepo(
- val calendarsFlow: MutableStateFlow> = MutableStateFlow(emptyList()),
- val instancesFlow: MutableStateFlow> = MutableStateFlow(emptyList()),
- ) : CalendarRepository {
- override fun calendars(): Flow> = calendarsFlow
- override fun instances(range: ClosedRange): Flow> = instancesFlow
- override suspend fun eventDetail(eventId: Long): EventDetail =
- throw NoSuchEventException(eventId)
- }
-
- private fun makeCal(id: Long, name: String) =
- CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
-
- private fun makeEvent(id: Long, title: String) = EventInstance(
- instanceId = id, eventId = id, calendarId = 1L,
- title = title,
- start = Instant.fromEpochMilliseconds(0L),
- end = Instant.fromEpochMilliseconds(60_000L),
- isAllDay = false, color = 0xFF000000.toInt(), location = null,
- )
-
- @Test
- fun `initial state is Loading then Success`() = runTest {
- val repo = FakeRepo()
- val vm = DebugViewModel(repo, UnconfinedTestDispatcher(testScheduler))
- vm.state.test {
- assertThat(awaitItem()).isEqualTo(DebugUiState.Loading)
-
- repo.calendarsFlow.value = listOf(makeCal(1L, "A"))
- repo.instancesFlow.value = listOf(makeEvent(10L, "X"))
-
- val success = awaitItem() as DebugUiState.Success
- assertThat(success.calendars.map { it.id }).containsExactly(1L)
- assertThat(success.nextEvents.map { it.title }).containsExactly("X")
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `instances are capped at 50`() = runTest {
- val repo = FakeRepo()
- val vm = DebugViewModel(repo, UnconfinedTestDispatcher(testScheduler))
- vm.state.test {
- awaitItem() // Loading
-
- repo.calendarsFlow.value = listOf(makeCal(1L, "A"))
- repo.instancesFlow.value = (1L..100L).map { makeEvent(it, "E$it") }
-
- val success = awaitItem() as DebugUiState.Success
- assertThat(success.nextEvents).hasSize(50)
- assertThat(success.nextEvents.first().instanceId).isEqualTo(1L)
- assertThat(success.nextEvents.last().instanceId).isEqualTo(50L)
- cancelAndIgnoreRemainingEvents()
- }
- }
-}
-```
-
-- [ ] **Step 3: Run test - confirm it fails**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.ui.debug.DebugViewModelTest'
-```
-
-Expected: FAIL with `Unresolved reference: DebugViewModel`.
-
-- [ ] **Step 4: Implement the ViewModel**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.debug
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
-import de.jeanlucmakiola.calendula.data.di.IoDispatcher
-import de.jeanlucmakiola.calendula.domain.FailureReason
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.datetime.Clock
-import kotlin.time.Duration.Companion.days
-import javax.inject.Inject
-
-private const val MAX_DEBUG_EVENTS = 50
-private val DEBUG_WINDOW = 30.days
-
-@HiltViewModel
-class DebugViewModel @Inject constructor(
- private val repository: CalendarRepository,
- @IoDispatcher private val io: CoroutineDispatcher,
-) : ViewModel() {
-
- val state: StateFlow = run {
- val now = Clock.System.now()
- val range = now..(now + DEBUG_WINDOW)
- combine(
- repository.calendars(),
- repository.instances(range),
- ) { calendars, instances ->
- DebugUiState.Success(
- calendars = calendars,
- nextEvents = instances.take(MAX_DEBUG_EVENTS),
- ) as DebugUiState
- }
- .catch { emit(DebugUiState.Failure(FailureReason.ProviderUnavailable)) }
- .flowOn(io)
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000L),
- initialValue = DebugUiState.Loading,
- )
- }
-}
-```
-
-- [ ] **Step 5: Run test - confirm it passes**
-
-```bash
-./gradlew :app:testDebugUnitTest --tests 'de.jeanlucmakiola.calendula.ui.debug.DebugViewModelTest'
-```
-
-Expected: PASS, 2 tests green.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt \
- app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt \
- app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt
-git commit -m "ui: add DebugViewModel combining calendars + next 30d instances"
-```
-
----
-
-## Task 16: Debug Screen Composable
-
-The screen visually validates that data is flowing.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugScreen.kt`
-
-- [ ] **Step 1: Implement `DebugScreen.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui.debug
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import de.jeanlucmakiola.calendula.R
-import de.jeanlucmakiola.calendula.domain.CalendarSource
-import de.jeanlucmakiola.calendula.domain.EventInstance
-import kotlinx.datetime.Instant
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.toLocalDateTime
-
-@Composable
-fun DebugScreen(
- modifier: Modifier = Modifier,
- viewModel: DebugViewModel = hiltViewModel(),
-) {
- val state by viewModel.state.collectAsStateWithLifecycle()
- Column(modifier = modifier.fillMaxSize()) {
- DebugBanner()
- when (val s = state) {
- DebugUiState.Loading -> LoadingContent()
- is DebugUiState.Failure -> FailureContent()
- is DebugUiState.Success -> SuccessContent(s)
- }
- }
-}
-
-@Composable
-private fun DebugBanner() {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.tertiaryContainer)
- .padding(horizontal = 16.dp, vertical = 8.dp),
- ) {
- Text(
- text = stringResource(R.string.debug_banner),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onTertiaryContainer,
- )
- }
-}
-
-@Composable
-private fun LoadingContent() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
- }
-}
-
-@Composable
-private fun FailureContent() {
- Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
- Text(
- text = stringResource(R.string.state_failure_provider),
- style = MaterialTheme.typography.bodyLarge,
- )
- }
-}
-
-@Composable
-private fun SuccessContent(state: DebugUiState.Success) {
- LazyColumn(
- modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- item { SectionHeader(stringResource(R.string.debug_calendars_header)) }
- if (state.calendars.isEmpty()) {
- item {
- Text(
- text = stringResource(R.string.debug_no_calendars),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- } else {
- items(state.calendars, key = { it.id }) { CalendarRow(it) }
- }
-
- item { Spacer(Modifier.height(16.dp)) }
- item { SectionHeader(stringResource(R.string.debug_events_header)) }
-
- if (state.nextEvents.isEmpty()) {
- item {
- Text(
- text = stringResource(R.string.debug_no_events),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- } else {
- items(state.nextEvents, key = { it.instanceId }) { EventRow(it) }
- }
- }
-}
-
-@Composable
-private fun SectionHeader(text: String) {
- Column(modifier = Modifier.padding(vertical = 8.dp)) {
- Text(text = text, style = MaterialTheme.typography.titleMedium)
- HorizontalDivider()
- }
-}
-
-@Composable
-private fun CalendarRow(cal: CalendarSource) {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
- ) {
- Box(
- modifier = Modifier
- .size(12.dp)
- .background(Color(cal.color), CircleShape),
- )
- Text(
- text = " ${cal.displayName} (${cal.accountName})",
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(start = 20.dp),
- )
- }
-}
-
-@Composable
-private fun EventRow(event: EventInstance) {
- val zone = TimeZone.currentSystemDefault()
- val start = event.start.toLocalDateTime(zone)
- Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
- Text(text = event.title, style = MaterialTheme.typography.bodyMedium)
- Text(
- text = formatStart(start),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-}
-
-private fun formatStart(start: kotlinx.datetime.LocalDateTime): String {
- val date = "%04d-%02d-%02d".format(start.year, start.monthNumber, start.dayOfMonth)
- val time = "%02d:%02d".format(start.hour, start.minute)
- return "$date $time"
-}
-```
-
-- [ ] **Step 2: Compile-check**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugScreen.kt
-git commit -m "ui: add DebugScreen showing calendars + next 50 instances"
-```
-
----
-
-## Task 17: RootScreen + MainActivity Routing
-
-Replace the Plan 01 placeholder. `MainActivity` now defers all routing to a single `RootScreen` composable that reads system permission state and chooses between `PermissionScreen` and `DebugScreen`.
-
-**Files:**
-- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt`
-- Modify: `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt`
-
-- [ ] **Step 1: Create `RootScreen.kt`**
-
-```kotlin
-package de.jeanlucmakiola.calendula.ui
-
-import android.Manifest
-import android.content.pm.PackageManager
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import de.jeanlucmakiola.calendula.ui.debug.DebugScreen
-import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
-
-@Composable
-fun RootScreen(modifier: Modifier = Modifier) {
- val context = LocalContext.current
- var hasPermission by remember {
- mutableStateOf(
- ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
- == PackageManager.PERMISSION_GRANTED
- )
- }
-
- val lifecycle = LocalLifecycleOwner.current.lifecycle
- LaunchedEffect(lifecycle) {
- val obs = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- hasPermission = ContextCompat.checkSelfPermission(
- context, Manifest.permission.READ_CALENDAR
- ) == PackageManager.PERMISSION_GRANTED
- }
- }
- lifecycle.addObserver(obs)
- }
-
- Scaffold(modifier = modifier) { innerPadding ->
- if (hasPermission) {
- DebugScreen(modifier = Modifier.padding(innerPadding))
- } else {
- PermissionScreen(
- onGranted = { hasPermission = true },
- modifier = Modifier.padding(innerPadding),
- )
- }
- }
-}
-```
-
-- [ ] **Step 2: Rewrite `MainActivity.kt`**
-
-Replace the existing `MainActivity.kt` with:
-
-```kotlin
-package de.jeanlucmakiola.calendula
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.ui.Modifier
-import dagger.hilt.android.AndroidEntryPoint
-import de.jeanlucmakiola.calendula.ui.RootScreen
-import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
-
-@AndroidEntryPoint
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContent {
- CalendulaTheme {
- RootScreen(modifier = Modifier.fillMaxSize())
- }
- }
- }
-}
-```
-
-The Plan 01 `PlaceholderScreen` and `PlaceholderPreview` composables are removed entirely.
-
-- [ ] **Step 3: Compile-check**
-
-```bash
-./gradlew :app:compileDebugKotlin
-```
-
-Expected: BUILD SUCCESSFUL. If you see `Unresolved reference: app_tagline`, that is fine — it was used only in the removed `PlaceholderScreen`. The string itself stays in `strings.xml` (other features may use it later).
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt \
- app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
-git commit -m "ui: replace placeholder with RootScreen routing permission ↔ debug"
-```
-
----
-
-## Task 18: Update MainActivitySmokeTest
-
-Plan 01's smoke test asserts on the placeholder's text. That text no longer renders. Replace with a smoke that runs without `READ_CALENDAR` granted (no `@get:Rule GrantPermissionRule`), confirming the permission rationale shows.
-
-**Files:**
-- Modify: `app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt`
-
-- [ ] **Step 1: Replace `MainActivitySmokeTest.kt`**
-
-Full file:
-
-```kotlin
-package de.jeanlucmakiola.calendula
-
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Smoke: launches MainActivity and asserts the permission rationale renders
- * when calendar access has not yet been granted. Without GrantPermissionRule
- * the system reports NOT GRANTED on first launch so we land in PermissionScreen.
- */
-@RunWith(AndroidJUnit4::class)
-class MainActivitySmokeTest {
-
- @get:Rule
- val composeTestRule = createAndroidComposeRule()
-
- private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
-
- @Test
- fun permissionRationale_isDisplayed_onLaunch_withoutPermission() {
- composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
- .assertIsDisplayed()
- }
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt
-git commit -m "test: replace placeholder smoke with permission-rationale assert"
-```
-
----
-
-## Task 19: Instrumented Repository Smoke Test
-
-A single end-to-end smoke against the real `CalendarContract` provider on the emulator. Uses `GrantPermissionRule` so we can call the repository without manually clicking the dialog.
-
-**Files:**
-- Create: `app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt`
-
-- [ ] **Step 1: Create the test**
-
-```kotlin
-package de.jeanlucmakiola.calendula.data.calendar
-
-import android.Manifest
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.rule.GrantPermissionRule
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-import kotlinx.datetime.Clock
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import kotlin.time.Duration.Companion.days
-
-/**
- * Smoke test against the real ContentResolver. Verifies that the repository
- * returns sane (possibly empty) lists and does not crash on a clean emulator.
- *
- * The emulator may or may not have any calendars configured — both outcomes
- * are valid for this test. We only assert "did not throw" and "returns a list".
- */
-@RunWith(AndroidJUnit4::class)
-class CalendarRepositorySmokeTest {
-
- @get:Rule
- val permissionRule: GrantPermissionRule =
- GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR)
-
- private val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- private fun newRepo(): CalendarRepositoryImpl {
- val resolver = AndroidCalendarContentResolver(context)
- return CalendarRepositoryImpl(resolver, Dispatchers.IO)
- }
-
- @Test
- fun calendars_returnsListWithoutCrashing() = runBlocking {
- val repo = newRepo()
- val first = repo.calendars().first()
- assertThat(first).isNotNull()
- }
-
- @Test
- fun instances_returnsListWithoutCrashing() = runBlocking {
- val repo = newRepo()
- val now = Clock.System.now()
- val first = repo.instances(now..(now + 1.days)).first()
- assertThat(first).isNotNull()
- }
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt
-git commit -m "test: instrumented repository smoke against real CalendarContract"
-```
-
----
-
-## Task 20: Update CHANGELOG, STATE, ROADMAP, REQUIREMENTS
-
-- [ ] **Step 1: Update `CHANGELOG.md`**
-
-Replace the `[Unreleased]` section so the file reads:
-
-```markdown
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-
-## [0.2.0] — 2026-06-08
-
-### Added
-- Domain models for calendars, event instances, event detail, attendees
-- `CalendarContract`-backed `CalendarRepository` with `ContentObserver`-driven live updates
-- DataStore preference for app-side hidden-calendar visibility
-- `READ_CALENDAR` permission flow (rationale + denied recovery + system-settings shortcut)
-- Wegwerfbarer Debug-Screen: zeigt alle Kalender + die nächsten 50 Termine ab heute
-- Hilt-Wiring für Data-Layer (Repository, ContentResolver, DataStore, IO-Dispatcher)
-- Unit-Tests für Cursor-Mapping (alle §8-Defensiv-Cases), Repository-Flows mit Turbine, DataStore round-trip
-- Instrumented smoke test against the real CalendarContract provider
-
-## [0.1.0] — 2026-06-08
-
-### Added
-- Initial project scaffold (Gradle Kotlin DSL, Version Catalog, Hilt, DataStore)
-- Material 3 Expressive theme with Dynamic Color (API 31+) and slate-derived fallback
-- Adaptive launcher icon — stylized "1" on slate squircle (references *kalendae*)
-- German + English localization infrastructure
-- Permission declaration for `READ_CALENDAR` (no UI flow yet — that's Plan 02)
-- Gitea CI workflow: lint, unit tests, debug build, Trivy scan
-- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
-- F-Droid metadata stubs (DE + EN short/full descriptions)
-- `.planning/` project-tracking documents
-```
-
-- [ ] **Step 2: Update `.planning/STATE.md`**
-
-```markdown
-# Calendula — Current State
-
-*Last updated: 2026-06-08*
-
-## Status
-
-**Milestone:** v0.2 — Data Layer & Permission Flow
-**Phase:** Plan 02 complete; UI-design iteration pending before Plan 03
-
-## Progress
-
-- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
-- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
-- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
-- [x] Plan 02 written and executed — data layer + permission flow + debug screen
-- [ ] UI-design iteration (mockups for Month/Week/Day/Detail/Filter/Settings, all three states)
-- [ ] Plan 03 (Month view)
-
-## Next
-
-1. Iterate on UI design (mockups per screen, all three states)
-2. Write Plan 03: Month view
-3. Execute Plan 03 — Debug screen gets replaced by month view
-```
-
-- [ ] **Step 3: Update `.planning/ROADMAP.md`**
-
-```markdown
-# Calendula — Roadmap
-
-## v0.x — Pre-Release
-
-| Version | Milestone | Status |
-|---|---|---|
-| v0.1 | Foundation & CI | complete |
-| v0.2 | Data Layer & Permission Flow | complete |
-| v0.3 | Month view | pending |
-| v0.4 | Week view | pending |
-| v0.5 | Day view | pending |
-| v0.6 | Event Detail Sheet | pending |
-| v0.7 | Filter & Settings | pending |
-
-## v1.0 — First Public Release
-
-All V1 features shipped, polished, on F-Droid. Read-only calendar.
-
-## v2.0 — Write Support
-
-- Event create / edit / delete via `CalendarContract` writes
-- Quick-add sheet
-- Conflict UX (event modified externally during edit)
-
-## v3.0 — Power-User Features
-
-- Home-screen widget
-- Full-text search
-- Tablet / foldable layouts
-- Optional: ICS file import (drag-and-drop)
-
-Order is indicative — community feedback after V1 may re-prioritize.
-```
-
-- [ ] **Step 4: Update `.planning/REQUIREMENTS.md`**
-
-Find the "Active (V1)" list and tick the relevant items so it reads:
-
-```markdown
-### Active (V1)
-
-- [x] Foundation & CI infrastructure
-- [x] Data Layer over `CalendarContract`
-- [x] Permission flow (`READ_CALENDAR`)
-- [ ] Month view (S1)
-- [ ] Week view (S2)
-- [ ] Day view (S3)
-- [ ] Event Detail Sheet (S4)
-- [ ] Multi-Calendar Filter (M3)
-- [ ] Today button + Jump-to-Date (M2)
-- [ ] View-Switcher (M1)
-- [ ] Settings screen (M4)
-- [ ] Empty / no-permission / no-calendars states
-- [ ] German + English localization
-- [ ] Loading/Failure/Success states per screen (architectural pattern)
-```
-
-The `### Validated (shipped)` block above stays at `(none yet — first milestone in progress)` — those tick only at v1.0.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add CHANGELOG.md .planning/STATE.md .planning/ROADMAP.md .planning/REQUIREMENTS.md
-git commit -m "docs: record v0.2.0 data-layer + permission flow in CHANGELOG, planning"
-```
-
----
-
-## Task 21: Final Verification
-
-- [ ] **Step 1: Run the full local verification**
-
-```bash
-./gradlew lint test assembleDebug
-```
-
-Expected: All three steps BUILD SUCCESSFUL. Unit tests count: 3 (Plan 01) + 3 (TimeBridge) + 5 (Models) + 4 (CalendarMapper) + 9 (InstanceMapper) + 6 (EventDetailMapper) + 5 (Repository) + 4 (CalendarPrefs) + 2 (DebugViewModel) = 41 unit tests, all green.
-
-- [ ] **Step 2: Install on a connected device / emulator and smoke-check manually**
-
-```bash
-./gradlew :app:installDebug
-adb shell am start -n de.jeanlucmakiola.calendula.debug/de.jeanlucmakiola.calendula.MainActivity
-```
-
-Expected UX:
-1. App opens to a Permission-Rationale-Screen showing "Kalender-Zugriff" (or "Calendar access" depending on locale) plus a "Weiter" / "Continue" button.
-2. Tapping the button triggers the system permission dialog.
-3. After granting, the Debug-Screen renders with a yellow "DEBUG — wird mit Plan 03 …" banner, the list of configured calendars, and the next 50 event instances.
-4. Tapping "Deny" instead lands on the Denied-Recovery-Screen with retry + system-settings buttons.
-
-- [ ] **Step 3: Run the instrumented test on the emulator**
-
-```bash
-./gradlew :app:connectedDebugAndroidTest
-```
-
-Expected: 3 instrumented tests green (MainActivitySmokeTest x1, PermissionScreenTest x1, CalendarRepositorySmokeTest x2). Skip locally if no emulator; CI runs them.
-
-- [ ] **Step 4: Push and let Gitea CI run**
-
-```bash
-git push origin main
-```
-
-Then observe the workflow run in Gitea. The same `.gitea/workflows/ci.yaml` from Plan 01 covers lint + test + assembleDebug; nothing in the workflow itself needed to change.
-
-- [ ] **Step 5: Tag v0.2.0 — Only after CI is green**
-
-```bash
-# After CI is green:
-git tag -a v0.2.0 -m "v0.2.0 — data layer & permission flow"
-git push origin v0.2.0
-```
-
-The release workflow signs and uploads to F-Droid. The Debug-Screen ships in v0.2.0 — explicit pre-release; users who install will see the banner that explains it.
-
----
-
-## Verification Checklist (post-execution)
-
-After all 21 tasks are completed, verify:
-
-- [ ] `./gradlew lint` exits 0
-- [ ] `./gradlew test` exits 0; all 41 unit tests pass
-- [ ] `./gradlew assembleDebug` produces a working APK
-- [ ] APK launches and renders the permission rationale on first run
-- [ ] Granting the permission swaps to the Debug screen showing calendars + events
-- [ ] Denying the permission shows the recovery screen with two buttons (retry, open settings)
-- [ ] "Open system settings" launches the app-info screen for `de.jeanlucmakiola.calendula.debug`
-- [ ] Modifying the system calendar externally (e.g. adding an event via the system calendar app or `adb shell content insert`) causes the Debug screen list to update without restart (ContentObserver works)
-- [ ] DE locale shows German UI; EN locale shows English UI
-- [ ] Light/Dark theme still respects system setting
-- [ ] On API 31+ Dynamic Color still picks up wallpaper colors
-- [ ] `.planning/STATE.md` reflects "Plan 02 complete"
-- [ ] `CHANGELOG.md` has a v0.2.0 entry
-
-On Gitea after pushing:
-
-- [ ] First CI run after the Plan 02 push is green
-- [ ] Tag `v0.2.0` triggers the release workflow successfully
-- [ ] F-Droid repo shows v0.2.0 alongside v0.1.0
-
----
-
-## What Plan 02 Does NOT Do (deferred to subsequent plans)
-
-- No Month / Week / Day views → Plans 03 / 04 / 05 (the Debug screen is a stop-gap that those plans replace)
-- No Event-Detail-Sheet → Plan 06 (the repository's `eventDetail(id)` is wired and tested, but no UI consumes it yet)
-- No Kalender-Filter-Sheet → Plan 07 (DataStore + `hiddenCalendarIds` is in place, but no UI surfaces the toggle yet, and the Debug screen ignores the hidden set on purpose)
-- No Settings screen → Plan 07
-- No UI-design polish on the Permission / Debug screens — they are deliberately raw; their visual treatment becomes part of the UI-design iteration that precedes Plan 03
-- No widget, no notifications, no search, no write support — those are V2/V3 milestones
-
-The data layer is the load-bearing piece of V1. Every later UI plan layers a thin Compose surface over this same repository — no new data-access code needed for screens.
diff --git a/docs/superpowers/plans/2026-06-11-03-write-support.md b/docs/superpowers/plans/2026-06-11-03-write-support.md
deleted file mode 100644
index 9a38798..0000000
--- a/docs/superpowers/plans/2026-06-11-03-write-support.md
+++ /dev/null
@@ -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 Occurrence−1s, 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)
diff --git a/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md b/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
deleted file mode 100644
index 04bf050..0000000
--- a/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
+++ /dev/null
@@ -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
- `` — 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.0–v1.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
diff --git a/docs/superpowers/plans/2026-06-18-05-ics-export.md b/docs/superpowers/plans/2026-06-18-05-ics-export.md
deleted file mode 100644
index b00b922..0000000
--- a/docs/superpowers/plans/2026-06-18-05-ics-export.md
+++ /dev/null
@@ -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=:`.
- 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. `@calendula`). Bestehende Events ohne UID exportieren
- wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
- `event-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-.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` = `-PTM`), `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 ?: "-@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.
diff --git a/docs/superpowers/plans/2026-06-18-06-ics-import.md b/docs/superpowers/plans/2026-06-18-06-ics-import.md
deleted file mode 100644
index 082740e..0000000
--- a/docs/superpowers/plans/2026-06-18-06-ics-import.md
+++ /dev/null
@@ -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` + `warnings: List`).
-`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=` → 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 `PS`.
-- `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
diff --git a/docs/superpowers/specs/2026-06-08-calendar-app-design.md b/docs/superpowers/specs/2026-06-08-calendar-app-design.md
deleted file mode 100644
index 90a0eef..0000000
--- a/docs/superpowers/specs/2026-06-08-calendar-app-design.md
+++ /dev/null
@@ -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.`
-
-## 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,
- 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>
- fun instances(range: ClosedRange): Flow>
- 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`,
- 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>,
- val visibleCalendars: List,
- ) : 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 (`` 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