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>
61 KiB
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— MITREADME.md— Short app description + build instructionsCHANGELOG.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.ymlfdroid-metadata/de.jeanlucmakiola.calendula/en-US/short_description.txtfdroid-metadata/de.jeanlucmakiola.calendula/en-US/full_description.txtfdroid-metadata/de.jeanlucmakiola.calendula/de/short_description.txtfdroid-metadata/de.jeanlucmakiola.calendula/de/full_description.txt
Gradle root:
settings.gradle.ktsbuild.gradle.ktsgradle.propertiesgradle/libs.versions.tomlgradle/wrapper/gradle-wrapper.properties(jar generated bygradle wrapper)
App module:
app/.gitignoreapp/build.gradle.ktsapp/proguard-rules.proapp/src/main/AndroidManifest.xmlapp/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.ktapp/src/main/java/de/jeanlucmakiola/calendula/MainActivity.ktapp/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.ktapp/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.ktapp/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.xmlapp/src/main/res/values/themes.xmlapp/src/main/res/drawable/ic_launcher_background.xmlapp/src/main/res/drawable/ic_launcher_foreground.xmlapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xmlapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Tests:
app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.ktapp/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 assembleDebugand 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
connectedDebugAndroidTestfor 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.ktswill pick up signing config from akey.propertiesfile 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 lintexits 0./gradlew testexits 0; all unit tests pass (ColorSchemeTest x3)./gradlew assembleDebugproduces a working APK atapp/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.mdreflects "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.0triggers 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, noContentResolverqueries → 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.