Files
calendula/docs/superpowers/plans/2026-06-08-01-foundation.md
Jean-Luc Makiola 42a1183b6f docs: add Plan 01 (Foundation & CI) implementation plan
First of an 8-plan sequence to build V1. Plan 01 covers the buildable
Android project scaffold: Gradle setup, Hilt, DataStore, Material 3
Expressive theme, adaptive launcher icon (statische "1" on slate
squircle, referencing kalendae), DE+EN i18n infrastructure, ColorScheme
unit tests, smoke UI test, Gitea CI workflow, F-Droid release workflow,
F-Droid metadata stubs, and .planning/ project-tracking documents.

14 tasks, each ending in a commit. Output is a working APK with green
CI before any feature code is written.

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

61 KiB
Raw Blame History

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.11.1, 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
# 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.

```bash
# Bootstrap the gradle wrapper (one-time, requires a host gradle install)
gradle wrapper --gradle-version 8.11.1 --distribution-type bin

# Build debug APK
./gradlew assembleDebug

# Run unit tests
./gradlew test

# Run lint
./gradlew lint

License

MIT — 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
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
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

# 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
# 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
# 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
# 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
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

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
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

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
[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)
// 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
# 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

This requires a host gradle installation. Run once:

gradle wrapper --gradle-version 8.11.1 --distribution-type bin

Expected: creates gradle/wrapper/gradle-wrapper.jar, gradle/wrapper/gradle-wrapper.properties, gradlew, gradlew.bat.

Then make gradlew executable:

chmod +x gradlew
  • Step 6: Verify gradle wrapper works
./gradlew --version

Expected output includes:

Gradle 8.11.1
Kotlin: 2.0.x

(Don't worry about the specific Kotlin version in the gradle output — that's gradle's bundled Kotlin, separate from our project Kotlin 2.1.0.)

  • Step 7: Commit
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
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
./gradlew help

Expected: Build succeeds. No project setup errors. Some dependency download output is normal on first run.

  • Step 5: Commit
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 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 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 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)
<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
<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
<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)
<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
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 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 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 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 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
./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
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:

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
./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
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
./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.

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
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
./gradlew assembleDebug

Expected: BUILD SUCCESSFUL.

  • Step 8: Commit
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

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
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)
./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
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

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)
./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
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

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
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
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
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
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)
./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
./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
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
./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:

# 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
# 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
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.

# 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.