Files
calendula/docs/superpowers/plans/2026-06-08-01-foundation.md
Jean-Luc Makiola f13523c865 docs(plan): adapt foundation plan to actual dev toolchain
Dev machine has no host gradle binary; bootstrap from HouseHoldKeaper's
wrapper (Gradle 8.14, compatible with AGP 8.7.2). Default JDK is 26,
but AGP 8.7.2 needs JDK 17-21; require JAVA_HOME=jdk-17 on local
invocations. CI is unaffected (setup-java pins 17).

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

2051 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:**
- Kotlin 2.1.0, Gradle 8.14 (bootstrapped from HouseHoldKeaper's wrapper), AGP 8.7.2, JVM Target 17
- minSdk 29, targetSdk 36, compileSdk 36
- Jetpack Compose BOM 2025.05.00, Material3 1.5.0 (Expressive APIs)
- Hilt 2.53 + KSP 2.1.0-1.0.29
- AndroidX DataStore Preferences 1.1.1
- Tests: JUnit5 5.11.x + Truth 1.4.4 + Compose UI Test (BOM)
- CI: Gitea Actions auf 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`**
```toml
[versions]
agp = "8.7.2"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
hilt = "2.53"
coreKtx = "1.15.0"
lifecycleRuntime = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2025.05.00"
material3 = "1.5.0"
datastore = "1.1.1"
junit = "5.11.4"
junitJupiterPlatform = "1.11.4"
truth = "1.4.4"
androidxJunit = "1.2.1"
espressoCore = "3.6.1"
[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 = "junitJupiterPlatform" }
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
```
Gradle 8.14 is compatible with AGP 8.7.2 (Gradle is backward-compatible for AGP versions; newer Gradle works fine).
- [ ] **Step 6: Verify gradle wrapper works (with JDK 17)**
The system default JDK is 26, but AGP 8.7.2's compatibility envelope is JDK 1721. Use JDK 17 explicitly for every gradle invocation in this plan:
```bash
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew --version
```
Expected output includes:
```
Gradle 8.14
```
> Going forward in this plan, every `./gradlew ...` invocation must be prefixed with `JAVA_HOME=/usr/lib/jvm/java-17-openjdk` on this dev machine. CI sets `setup-java@v4 java-version: 17` so no prefix is needed there.
- [ ] **Step 7: 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
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
testOptions {
unitTests.all { test ->
test.useJUnitPlatform()
}
}
}
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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_CALENDAR" />
<application
android:name=".CalendulaApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Calendula"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Calendula">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
- [ ] **Step 2: Create backup-rules placeholder XML files**
Create `app/src/main/res/xml/backup_rules.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
</full-backup-content>
```
Create `app/src/main/res/xml/data_extraction_rules.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<!-- Allow DataStore backup, exclude nothing extra. -->
</cloud-backup>
<device-transfer>
</device-transfer>
</data-extraction-rules>
```
- [ ] **Step 3: Create `app/src/main/res/values/strings.xml` (English master)**
```xml
<resources>
<string name="app_name">Calendula</string>
<string name="app_tagline">A modern calendar.</string>
<!-- Loading / Failure / Success generic strings (used across screens) -->
<string name="state_loading">Loading…</string>
<string name="state_retry">Retry</string>
<string name="state_failure_unknown">Something went wrong.</string>
<string name="state_failure_permission">Calendar access is required.</string>
<string name="state_failure_permission_action">Grant access</string>
<string name="state_failure_no_calendars">No calendars configured.</string>
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</string>
</resources>
```
- [ ] **Step 4: Create `app/src/main/res/values-de/strings.xml`**
```xml
<resources>
<string name="app_name">Calendula</string>
<string name="app_tagline">Ein moderner Kalender.</string>
<string name="state_loading">Lädt…</string>
<string name="state_retry">Erneut versuchen</string>
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
<string name="state_failure_permission">Zugriff auf den Kalender wird benötigt.</string>
<string name="state_failure_permission_action">Zugriff erlauben</string>
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
</resources>
```
- [ ] **Step 5: Create `app/src/main/res/values/colors.xml`**
```xml
<resources>
<!-- Seed color: desaturated slate blue-gray. Material 3 derives Light & Dark schemes from this. -->
<color name="seed">#FF5C6B7A</color>
<!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF5C6B7A</color>
</resources>
```
- [ ] **Step 6: Create `app/src/main/res/values/themes.xml`** (minimal stub — Compose handles real theming)
```xml
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Calendula" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
```
- [ ] **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
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/ic_launcher_background" />
</shape>
```
- [ ] **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
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
Stylized "1" centered in the 108×108 viewport.
Reference: kalendae (the first day of the month) — etymological root
of both "Calendar" and "Calendula".
Color is off-white for high contrast on the slate background.
-->
<path
android:fillColor="#FFFAF6F0"
android:pathData="M51.5,38 L51.5,38 C49.5,40 46.5,41.5 43,42.5 L43,49 C46.2,48.2 49,47 51.5,45.5 L51.5,72 L43.5,72 L43.5,76 L65.5,76 L65.5,72 L57.5,72 L57.5,38 Z" />
</vector>
```
- [ ] **Step 3: Create `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml`**
```xml
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
```
- [ ] **Step 4: Create `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml`**
```xml
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
```
- [ ] **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<MainActivity>()
@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 <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon
- name: Setup F-Droid Server Tools
run: |
SUDO=""
if command -v sudo >/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
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
testOptions {
unitTests.all { test ->
test.useJUnitPlatform()
}
}
}
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://<your-hetzner-host>/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.