Files
calendula/docs/superpowers/specs/2026-06-08-calendar-app-design.md
Jean-Luc Makiola 473f0d2004 docs: add V1 design spec for calendar app
Initial design document for the Material 3 Expressive calendar app.
Covers scope (V1 read-only MVP, variant "B"), tech stack (Kotlin +
Compose + Material3 Expressive, minSdk 29), architecture, data flow
over CalendarContract, screens/menus, the mandatory Loading/Failure/
Success state pattern per screen, error handling, i18n, accessibility,
testing approach, and CI/CD adaptation from HouseHoldKeaper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 14:26:17 +02:00

15 KiB

Calendar App - 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
  • 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+)

  • 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

  • Package: de.jeanlucmakiola.cal (Name "cal" als Platzhalter, finalisierung später)
  • Convention identisch zu HouseHoldKeaper: de.jeanlucmakiola.<app_name>

4. Architektur

Modul-Struktur

Single Gradle module :app für V1. Feature-Split (:core, :feature-*) erst wenn nötig - YAGNI.

Package-Layout

de.jeanlucmakiola.cal/
├── App.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)

data class CalendarSource(
    val id: Long,
    val displayName: String,
    val accountName: String,    // z.B. "jlmak@nextcloud.example.com"
    val accountType: String,    // z.B. "at.bitfire.davdroid" (DAVx5), "com.google" (Google), "LOCAL"
    val color: Int,
    val isVisibleInSystem: Boolean,  // CalendarContract.Calendars.VISIBLE
)

data class EventInstance(
    val instanceId: Long,        // Instances._ID (eindeutig pro Vorkommen)
    val eventId: Long,           // Events._ID (gleich für alle Vorkommen)
    val calendarId: Long,
    val title: String,
    val start: Instant,
    val end: Instant,
    val isAllDay: Boolean,
    val color: Int,              // Effektiv: Event.color ?: Calendar.color
    val location: String?,
)

data class EventDetail(
    val instance: EventInstance,
    val description: String?,
    val organizer: String?,
    val attendees: List<Attendee>,
    val rrule: String?,          // Read-only: nur "wiederkehrt wöchentlich" anzeigen
)

data class Attendee(val name: String, val email: String?, val status: AttendeeStatus)
enum class AttendeeStatus { Accepted, Declined, Tentative, NeedsAction, Unknown }

Repository

interface CalendarRepository {
    fun calendars(): Flow<List<CalendarSource>>
    fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
    suspend fun eventDetail(eventId: Long): EventDetail
}

Implementation-Details:

  • Hält ContentObserver auf CalendarContract.CONTENT_URI registriert
  • Bei Observer-Trigger: re-query, emit neuer Wert via SharedFlow
  • Queries laufen auf Dispatchers.IO
  • instances(range) nutzt CalendarContract.Instances.CONTENT_BY_DAY_URI oder CONTENT_URI mit Time-Range - Recurrence-Expansion macht der Provider
  • App-eigene "ausgeblendete Kalender-IDs" leben in DataStore als Set<Long>, kombinieren via combine() mit Calendar-Flow

ViewModel-Pattern

Ein ViewModel pro Top-Level-Screen. State immer als sealed interface (siehe Section 7 - Loading/Failure/Success).

ViewModel kennt aktuelle "Cursor"-Position (welcher Monat/Woche/Tag) und fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".

6. Screens & Menüs

Hauptscreens

S1 - Monatsansicht

  • Zeigt einen Monat im Überblick
  • Pro Tag erkennbar: hat-Events / keine-Events, ggf. Andeutung über Anzahl/Farbe
  • Navigation: vorwärts/zurück zwischen Monaten
  • Tap auf Tag → Tagesansicht für diesen Tag
  • Heute deutlich markiert

S2 - Wochenansicht

  • Zeigt eine Woche mit Zeitschiene
  • Events auf ihrer Uhrzeit, Calendar-Farbe
  • Overlap-Events: nebeneinander aufgelöst
  • All-Day-Events extra dargestellt
  • Navigation: vorwärts/zurück zwischen Wochen
  • Tap Event → Event-Detail-Sheet

S3 - Tagesansicht

  • Eine Spalte, mehr Detail pro Event als Wochenansicht
  • All-Day-Events extra
  • Navigation: vorwärts/zurück zwischen Tagen
  • Tap Event → Event-Detail-Sheet

S4 - Event-Detail-Sheet (Bottom-Sheet, ModalBottomSheet)

  • Pflicht-Inhalte: Titel, Start/Ende oder "Ganztägig", Kalender-Zugehörigkeit
  • Konditional: Ort (Tap → Maps-Intent), Beschreibung, Teilnehmer, RRULE-Hinweis
  • Dismissable via Drag oder Back-Geste

Menüs

M1 - View-Switcher

  • Wechsel zwischen Monat / Woche / Tag
  • Immer erreichbar von allen Hauptansichten
  • State persistent (zuletzt aktive Ansicht)

M2 - Heute / Springe-zu-Datum

  • Schnell zurück zu "heute"
  • Springe zu beliebigem Datum via Datum-Picker
  • 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

sealed interface MonthUiState {
    data object Loading : MonthUiState
    data class Failure(val reason: FailureReason) : MonthUiState
    data class Success(
        val month: YearMonth,
        val eventsPerDay: Map<LocalDate, List<EventInstance>>,
        val visibleCalendars: List<CalendarSource>,
    ) : MonthUiState
}

enum class FailureReason {
    PermissionRevoked,        // → Re-Request-Screen
    NoCalendarsConfigured,    // → Empty-State mit Intent zu System-Settings
    ProviderUnavailable,      // → Retry-Screen
    EventNotFound,            // → nur für Event-Detail-Sheet
    Unknown,                  // → Fallback
}

Composable-Dispatch

@Composable
fun MonthScreen(viewModel: MonthViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    when (val s = state) {
        is MonthUiState.Loading -> MonthLoadingScreen()
        is MonthUiState.Failure -> MonthFailureScreen(s.reason, onRetry = viewModel::retry)
        is MonthUiState.Success -> MonthSuccessScreen(s, ...)
    }
}

Pflicht-Composables pro Screen

Screen Loading Failure-Varianten Success
Monat Skelett-Grid (Shimmer) Permission, NoCalendars, Provider Grid mit Events
Woche Skelett-Schiene Permission, NoCalendars, Provider Schiene mit Events
Tag Skelett-Schiene Permission, NoCalendars, Provider Schiene mit Events
Event-Detail kompakter Skelett-Sheet EventNotFound, Provider Voll-Detail
Kalender-Filter Skelett-Liste Provider Liste
Settings sofort (DataStore ist instant) - direkt

Regeln:

  • Loading ist ein bewusst gestalteter Screen (Skeleton + Shimmer), kein loser Spinner
  • Failure ist ein eigener Screen mit Erklärung + Recovery-Action, kein Toast
  • Beim UI-Design später: alle drei Varianten pro Screen skizzieren, nie nur Success
  • Tests: pro Screen mindestens renders_loading, renders_failure, renders_success

8. Error Handling & Edge Cases

Philosophie

Calendar-Apps dürfen niemals an leeren/malformen Daten crashen. Defensive Validierung im Repository, kaputte Instanzen still droppen + via Log.w loggen.

Konkrete Fehlerfälle

Fall Verhalten
ContentResolver-Query wirft (Permission revoked zur Laufzeit) State → Failure(PermissionRevoked), UI zeigt Re-Request
Calendar displayName null Fallback "(Unbenannter Kalender)"
Event title null/leer Fallback "(Ohne Titel)"
dtend < dtstart Event droppen, Warn-Log
dtstart vor Unix-Epoch Event droppen, Warn-Log
Maps-Intent fehlt SnackBar "Keine Karten-App installiert"
DataStore-IO-Fehler Defaults verwenden, weiter, Warn-Log

Edge Cases im UI

  • All-Day-Events über mehrere Tage: in Wochen-/Tagesansicht über mehrere Tage gespannter All-Day-Strip
  • Events über Mitternacht: in Wochen-/Tagesansicht am Folgetag fortsetzen
  • Instant Events (start == end): Mindesthöhe rendern für Tap-Target
  • Viele Events an einem Tag: in Monatsansicht "+N more" statt Overflow
  • Timezones: alle Berechnungen in Geräte-Local-TZ, außer all-day = floating

9. i18n & Accessibility

i18n

  • res/values/strings.xml = englische Master-Strings
  • res/values-de/strings.xml = deutsche Übersetzungen
  • Alle Strings extrahiert, auch Plurals (<plurals> für "1 Event" / "N Events")
  • Wochentags-/Monatsnamen via java.time.format.DateTimeFormatter mit aktiver Locale (kein Hardcoding)
  • Sprach-Override aus Settings via AppCompatDelegate.setApplicationLocales

Accessibility (V1-Minimum)

  • Alle interaktiven Elemente: contentDescription, Tap-Target ≥ 48dp
  • Event-Items: semantisches Label "Titel, Start-Zeit, Dauer, Kalender X"
  • Calendar-Color immer mit Label/Form kombiniert (nie nur-Farbe-Information)
  • Dynamic-Text-Size respektiert (keine fixen sp-Werte für Text)
  • Hoher-Kontrast: M3-Theme reagiert automatisch
  • TalkBack-Smoke-Tests im UI-Test-Plan

10. Testing

Best Practices, kein Diskussionsbedarf:

  • Unit-Tests: JUnit5 + Truth + Turbine. Repository, ViewModels, Date/Time-Helpers, ContentResolver-Wrapper (mit Mock-Cursor)
  • UI-Tests: Compose UI Test pro Screen, mindestens renders_loading / renders_failure / renders_success + 1-2 Interaktions-Tests
  • Coverage-Ziel: pragmatisch ~70% lines, 100% für Repository + Date-Logik. Kein Coverage-Gate in CI, aber Pflicht-Run
  • Instrumented-Tests: nur für ContentResolver-Integration (echte CalendarContract-Queries auf Emulator)

11. CI/CD

Adaption der HouseHoldKeaper-Pipeline, nur Flutter-Steps durch Gradle ersetzt.

.gitea/workflows/ci.yaml (push + PR)

  • Setup Java 17 + Android SDK 36
  • ./gradlew lint
  • ./gradlew test
  • ./gradlew assembleDebug
  • Trivy Filesystem-Scan (HIGH/CRITICAL, continue-on-error wie HHK)

.gitea/workflows/release.yaml (auf Git-Tags)

  • Alles aus ci.yaml
  • Version aus Git-Tag in app/build.gradle.kts:
    • versionName = "${tag#v}"
    • versionCode = MAJOR*10000 + MINOR*100 + PATCH (HHK-Konvention)
  • Keystore aus Gitea Secrets (KEYSTORE_BASE64, KEY_PASSWORD, KEY_ALIAS)
  • ./gradlew assembleRelease
  • F-Droid-Pipeline 1:1 wie HHK: Hetzner-Sync, fdroid update -c, Re-Upload

Repo-Konventionen

  • CHANGELOG.md wird beim Taggen gepflegt (patch/minor/major)
  • fdroid-metadata/de.jeanlucmakiola.cal/ Verzeichnis-Struktur
  • LICENSE = MIT, Jean-Luc Makiola, 2026
  • .planning/ mit PROJECT.md, REQUIREMENTS.md, ROADMAP.md, STATE.md

12. Open Decisions

Punkt Status
Finaler App-Name (Platzhalter cal) offen, später
Konkretes UI-Layout pro Screen (Mockups, Komponenten-Wahl) offen, eigene Design-Iteration nach Spec-Approval
Theme-Seed-Color (Hex) für Fallback wenn kein Dynamic Color offen, später beim Design
Icon (Adaptive Launcher + Foreground) offen, später beim Design

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