From bb7954d026b4ef6ecf152b61278f7e1242629ec4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 17:38:41 +0200 Subject: [PATCH] Remove superpowers planning docs; ignore CLAUDE.md Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + .../plans/2026-06-08-01-foundation.md | 2068 ----------- ...06-08-02-data-layer-and-permission-flow.md | 3177 ----------------- .../plans/2026-06-11-03-write-support.md | 204 -- .../2026-06-11-04-reminder-notifications.md | 119 - .../plans/2026-06-18-05-ics-export.md | 150 - .../plans/2026-06-18-06-ics-import.md | 122 - .../specs/2026-06-08-calendar-app-design.md | 406 --- 8 files changed, 3 insertions(+), 6246 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-08-01-foundation.md delete mode 100644 docs/superpowers/plans/2026-06-08-02-data-layer-and-permission-flow.md delete mode 100644 docs/superpowers/plans/2026-06-11-03-write-support.md delete mode 100644 docs/superpowers/plans/2026-06-11-04-reminder-notifications.md delete mode 100644 docs/superpowers/plans/2026-06-18-05-ics-export.md delete mode 100644 docs/superpowers/plans/2026-06-18-06-ics-import.md delete mode 100644 docs/superpowers/specs/2026-06-08-calendar-app-design.md 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