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>
2048 lines
61 KiB
Markdown
2048 lines
61 KiB
Markdown
# 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`**
|
||
|
||
```markdown
|
||
# Calendula
|
||
|
||
A modern Material 3 Expressive calendar app for Android.
|
||
|
||
Calendula is named after the flower of the same name, whose name comes from
|
||
the Latin *kalendae* — the first day of the month — the same root as the
|
||
word "calendar". Calendula reads from Android's built-in `CalendarContract`,
|
||
so any calendar source synced to your device (CalDAV via DAVx5, Google,
|
||
local, WebCal subscriptions, ...) is shown.
|
||
|
||
## Features (V1)
|
||
|
||
- Month, Week, and Day views
|
||
- Read-only event details (write support comes in V2)
|
||
- Multi-calendar visibility toggle
|
||
- Material You Dynamic Color (Android 12+)
|
||
- Light/Dark theme follows system
|
||
- German + English UI
|
||
|
||
## Building
|
||
|
||
Requires Android SDK 36 and JDK 17.
|
||
|
||
```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](LICENSE) — Jean-Luc Makiola, 2026
|
||
```
|
||
|
||
- [ ] **Step 3: Create `CHANGELOG.md`**
|
||
|
||
```markdown
|
||
# Changelog
|
||
|
||
All notable changes to this project will be documented in this file.
|
||
|
||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
## [Unreleased]
|
||
|
||
### Added
|
||
- Initial project scaffold with Material 3 Expressive theme
|
||
- Adaptive launcher icon (numeral "1" on slate squircle, referencing *kalendae*)
|
||
- German + English localization infrastructure
|
||
- Hilt + DataStore dependency injection and preferences setup
|
||
- Gitea CI/CD workflows for build, test, lint, and release
|
||
```
|
||
|
||
- [ ] **Step 4: Create `.gitignore`**
|
||
|
||
```
|
||
# Built application files
|
||
*.apk
|
||
*.aar
|
||
*.ap_
|
||
*.aab
|
||
|
||
# Files for the ART/Dalvik VM
|
||
*.dex
|
||
|
||
# Java class files
|
||
*.class
|
||
|
||
# Generated files
|
||
bin/
|
||
gen/
|
||
out/
|
||
release/
|
||
|
||
# Gradle files
|
||
.gradle/
|
||
build/
|
||
|
||
# Local configuration file (sdk path, etc)
|
||
local.properties
|
||
|
||
# Proguard folder generated by Eclipse
|
||
proguard/
|
||
|
||
# Log files
|
||
*.log
|
||
|
||
# Android Studio / IntelliJ
|
||
*.iml
|
||
.idea/
|
||
.navigation/
|
||
captures/
|
||
.externalNativeBuild/
|
||
.cxx/
|
||
|
||
# Keystore files
|
||
*.jks
|
||
*.keystore
|
||
/key.properties
|
||
|
||
# Google Services (e.g. APIs or Firebase)
|
||
google-services.json
|
||
|
||
# OS files
|
||
.DS_Store
|
||
Thumbs.db
|
||
|
||
# F-Droid local artifacts (the pipeline generates them in CI)
|
||
fdroid/repo/
|
||
fdroid/keystore.p12
|
||
|
||
# KSP
|
||
.ksp/
|
||
```
|
||
|
||
- [ ] **Step 5: Create `.gitattributes`**
|
||
|
||
```
|
||
* text=auto eol=lf
|
||
*.bat text eol=crlf
|
||
*.jar binary
|
||
*.png binary
|
||
*.jpg binary
|
||
*.gif binary
|
||
*.webp binary
|
||
```
|
||
|
||
- [ ] **Step 6: Create `.editorconfig`**
|
||
|
||
```ini
|
||
root = true
|
||
|
||
[*]
|
||
charset = utf-8
|
||
end_of_line = lf
|
||
indent_style = space
|
||
indent_size = 4
|
||
insert_final_newline = true
|
||
trim_trailing_whitespace = true
|
||
|
||
[*.{yml,yaml,toml,json,md}]
|
||
indent_size = 2
|
||
|
||
[*.{kt,kts}]
|
||
ij_kotlin_packages_to_use_import_on_demand = unset
|
||
|
||
[Makefile]
|
||
indent_style = tab
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add LICENSE README.md CHANGELOG.md .gitignore .gitattributes .editorconfig
|
||
git commit -m "chore: add repo meta files (LICENSE, README, CHANGELOG, gitignore)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Project Planning Docs (`.planning/`)
|
||
|
||
Adopts the HouseHoldKeaper convention. These docs live alongside the spec and track higher-level project state.
|
||
|
||
**Files:**
|
||
- Create: `.planning/PROJECT.md`
|
||
- Create: `.planning/REQUIREMENTS.md`
|
||
- Create: `.planning/ROADMAP.md`
|
||
- Create: `.planning/STATE.md`
|
||
|
||
- [ ] **Step 1: Create `.planning/PROJECT.md`**
|
||
|
||
```markdown
|
||
# Calendula
|
||
|
||
## What This Is
|
||
|
||
A modern Material 3 Expressive Android calendar app, read-only V1. Lives
|
||
entirely on top of Android's `CalendarContract` — any calendar synced to the
|
||
device (CalDAV via DAVx5, Google, local, WebCal, …) shows up automatically.
|
||
The differentiator is visual: real Material 3 Expressive design that no
|
||
existing FOSS calendar app delivers.
|
||
|
||
## Core Value
|
||
|
||
A calendar app that is genuinely pleasant to look at and use, without
|
||
re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
||
|
||
## Current Milestone
|
||
|
||
**v0.1 — Foundation & CI:** Buildable Android project scaffold with theme,
|
||
icon, i18n, Hilt, DataStore, green CI.
|
||
|
||
## Stack
|
||
|
||
Kotlin 2.1, Jetpack Compose + Material 3 Expressive, Hilt, DataStore.
|
||
Gradle Kotlin DSL with Version Catalog.
|
||
|
||
Read-only V1, write support V2.
|
||
|
||
Android-only (minSdk 29, targetSdk 36). No iOS.
|
||
|
||
## Naming
|
||
|
||
"Calendula" — Latin *kalendae* ("first day of the month", root of "calendar")
|
||
is also the etymological root of the marigold flower Calendula. The icon
|
||
shows a stylized "1" on a slate squircle.
|
||
|
||
## Source
|
||
|
||
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
|
||
Hetzner. Same infrastructure as `HouseHoldKeaper`.
|
||
```
|
||
|
||
- [ ] **Step 2: Create `.planning/REQUIREMENTS.md`**
|
||
|
||
```markdown
|
||
# Calendula — Requirements
|
||
|
||
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
||
|
||
## V1 Scope (Variant "B")
|
||
|
||
### Validated (shipped)
|
||
- (none yet — first milestone in progress)
|
||
|
||
### Active (V1)
|
||
|
||
- [ ] Foundation & CI infrastructure
|
||
- [ ] Data Layer over `CalendarContract`
|
||
- [ ] Permission flow (`READ_CALENDAR`)
|
||
- [ ] Month view (S1)
|
||
- [ ] Week view (S2)
|
||
- [ ] Day view (S3)
|
||
- [ ] Event Detail Sheet (S4)
|
||
- [ ] Multi-Calendar Filter (M3)
|
||
- [ ] Today button + Jump-to-Date (M2)
|
||
- [ ] View-Switcher (M1)
|
||
- [ ] Settings screen (M4)
|
||
- [ ] Empty / no-permission / no-calendars states
|
||
- [ ] German + English localization
|
||
- [ ] Loading/Failure/Success states per screen (architectural pattern)
|
||
|
||
### Out of Scope (V2+)
|
||
|
||
- Event create / edit / delete (V2)
|
||
- Home-screen widget
|
||
- Full-text search
|
||
- Quick-add
|
||
- Custom notifications/reminders (system already handles these)
|
||
- Tablet/foldable-specific layouts
|
||
- iOS support (Android-only by design)
|
||
|
||
## Constraints
|
||
|
||
- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
|
||
- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
|
||
- **Offline-first:** all data lives in `CalendarContract`; no app-side network
|
||
- **Privacy:** zero telemetry, no analytics
|
||
- **i18n:** German + English from day one
|
||
- **Tests + CI from day one**
|
||
- **License:** MIT
|
||
```
|
||
|
||
- [ ] **Step 3: Create `.planning/ROADMAP.md`**
|
||
|
||
```markdown
|
||
# Calendula — Roadmap
|
||
|
||
## v0.x — Pre-Release
|
||
|
||
| Version | Milestone | Status |
|
||
|---|---|---|
|
||
| v0.1 | Foundation & CI | in progress |
|
||
| v0.2 | Data Layer & Permission Flow | pending |
|
||
| v0.3 | Month view | pending |
|
||
| v0.4 | Week view | pending |
|
||
| v0.5 | Day view | pending |
|
||
| v0.6 | Event Detail Sheet | pending |
|
||
| v0.7 | Filter & Settings | pending |
|
||
|
||
## v1.0 — First Public Release
|
||
|
||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
||
|
||
## v2.0 — Write Support
|
||
|
||
- Event create / edit / delete via `CalendarContract` writes
|
||
- Quick-add sheet
|
||
- Conflict UX (event modified externally during edit)
|
||
|
||
## v3.0 — Power-User Features
|
||
|
||
- Home-screen widget
|
||
- Full-text search
|
||
- Tablet / foldable layouts
|
||
- Optional: ICS file import (drag-and-drop)
|
||
|
||
Order is indicative — community feedback after V1 may re-prioritize.
|
||
```
|
||
|
||
- [ ] **Step 4: Create `.planning/STATE.md`**
|
||
|
||
```markdown
|
||
# Calendula — Current State
|
||
|
||
*Last updated: 2026-06-08*
|
||
|
||
## Status
|
||
|
||
**Milestone:** v0.1 — Foundation & CI
|
||
**Phase:** Implementation starting
|
||
|
||
## Progress
|
||
|
||
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
|
||
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
||
- [x] Plan 01 written (`docs/superpowers/plans/2026-06-08-01-foundation.md`)
|
||
- [ ] Plan 01 executed
|
||
|
||
## Next
|
||
|
||
1. Execute Plan 01 to land foundation
|
||
2. Write and execute Plan 02 (Data Layer & Permission Flow)
|
||
3. Iterate on UI design (mockups) before screens are built
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add .planning/
|
||
git commit -m "docs: add .planning/ project-tracking documents"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: F-Droid Metadata Stub
|
||
|
||
**Files:**
|
||
- Create: `fdroid-metadata/de.jeanlucmakiola.calendula.yml`
|
||
- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/short_description.txt`
|
||
- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/full_description.txt`
|
||
- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/de/short_description.txt`
|
||
- Create: `fdroid-metadata/de.jeanlucmakiola.calendula/de/full_description.txt`
|
||
|
||
- [ ] **Step 1: Create `fdroid-metadata/de.jeanlucmakiola.calendula.yml`**
|
||
|
||
```yaml
|
||
AuthorName: Jean-Luc Makiola
|
||
License: MIT
|
||
Name: Calendula
|
||
|
||
Categories:
|
||
- Time
|
||
|
||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||
```
|
||
|
||
- [ ] **Step 2: Create `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/short_description.txt`**
|
||
|
||
```
|
||
A modern Material 3 Expressive calendar for Android.
|
||
```
|
||
|
||
- [ ] **Step 3: Create `fdroid-metadata/de.jeanlucmakiola.calendula/en-US/full_description.txt`**
|
||
|
||
```
|
||
Calendula is a modern, open-source calendar app for Android. It reads from
|
||
the system calendar provider, so any source synced to your device — Nextcloud
|
||
via DAVx5, Google, local, WebCal subscriptions — shows up automatically.
|
||
|
||
The differentiator is the design: real Material 3 Expressive throughout, with
|
||
dynamic color, expressive motion, and expressive shapes.
|
||
|
||
V1 is read-only. Event creation, editing, and deletion are planned for V2.
|
||
|
||
Privacy: zero telemetry, no analytics, no network access — your data never
|
||
leaves the device.
|
||
```
|
||
|
||
- [ ] **Step 4: Create `fdroid-metadata/de.jeanlucmakiola.calendula/de/short_description.txt`**
|
||
|
||
```
|
||
Ein moderner Material-3-Expressive-Kalender für Android.
|
||
```
|
||
|
||
- [ ] **Step 5: Create `fdroid-metadata/de.jeanlucmakiola.calendula/de/full_description.txt`**
|
||
|
||
```
|
||
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie liest
|
||
direkt aus dem System-Kalender-Provider — jede Quelle, die mit deinem Gerät
|
||
synchronisiert ist (Nextcloud über DAVx5, Google, lokal, WebCal-Subscriptions)
|
||
erscheint automatisch.
|
||
|
||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||
|
||
V1 ist read-only. Erstellen, Bearbeiten und Löschen von Events kommt mit V2.
|
||
|
||
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — deine
|
||
Daten bleiben auf dem Gerät.
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add fdroid-metadata/
|
||
git commit -m "chore: add F-Droid metadata for de.jeanlucmakiola.calendula"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Gradle Root Setup
|
||
|
||
**Files:**
|
||
- Create: `settings.gradle.kts`
|
||
- Create: `build.gradle.kts`
|
||
- Create: `gradle.properties`
|
||
- Create: `gradle/libs.versions.toml`
|
||
|
||
- [ ] **Step 1: Create `settings.gradle.kts`**
|
||
|
||
```kotlin
|
||
pluginManagement {
|
||
repositories {
|
||
google {
|
||
content {
|
||
includeGroupByRegex("com\\.android.*")
|
||
includeGroupByRegex("com\\.google.*")
|
||
includeGroupByRegex("androidx.*")
|
||
}
|
||
}
|
||
mavenCentral()
|
||
gradlePluginPortal()
|
||
}
|
||
}
|
||
|
||
dependencyResolutionManagement {
|
||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||
repositories {
|
||
google()
|
||
mavenCentral()
|
||
}
|
||
}
|
||
|
||
rootProject.name = "Calendula"
|
||
include(":app")
|
||
```
|
||
|
||
- [ ] **Step 2: Create `gradle/libs.versions.toml`**
|
||
|
||
```toml
|
||
[versions]
|
||
agp = "8.7.2"
|
||
kotlin = "2.1.0"
|
||
ksp = "2.1.0-1.0.29"
|
||
hilt = "2.53"
|
||
coreKtx = "1.15.0"
|
||
lifecycleRuntime = "2.8.7"
|
||
activityCompose = "1.9.3"
|
||
composeBom = "2025.05.00"
|
||
material3 = "1.5.0"
|
||
datastore = "1.1.1"
|
||
junit = "5.11.4"
|
||
junitJupiterPlatform = "1.11.4"
|
||
truth = "1.4.4"
|
||
androidxJunit = "1.2.1"
|
||
espressoCore = "3.6.1"
|
||
|
||
[libraries]
|
||
# AndroidX core
|
||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||
|
||
# Compose BOM + libs
|
||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||
|
||
# Material 3 (Expressive lives in this artifact for 1.5+)
|
||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||
|
||
# Hilt
|
||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||
|
||
# DataStore
|
||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||
|
||
# Unit tests
|
||
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
|
||
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
|
||
junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitJupiterPlatform" }
|
||
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
|
||
|
||
# Android tests
|
||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||
|
||
[plugins]
|
||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||
```
|
||
|
||
- [ ] **Step 3: Create `build.gradle.kts` (root)**
|
||
|
||
```kotlin
|
||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||
plugins {
|
||
alias(libs.plugins.android.application) apply false
|
||
alias(libs.plugins.kotlin.android) apply false
|
||
alias(libs.plugins.kotlin.compose) apply false
|
||
alias(libs.plugins.ksp) apply false
|
||
alias(libs.plugins.hilt) apply false
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `gradle.properties`**
|
||
|
||
```properties
|
||
# Project-wide Gradle settings.
|
||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||
|
||
# Required for AndroidX.
|
||
android.useAndroidX=true
|
||
|
||
# Kotlin code style for this project: "official" or "obsolete".
|
||
kotlin.code.style=official
|
||
|
||
# Enables namespacing of each library's R class, less RAM, faster builds.
|
||
android.nonTransitiveRClass=true
|
||
|
||
# Use new K2 compiler defaults (Kotlin 2.x).
|
||
kotlin.incremental=true
|
||
|
||
# Reproducible builds for F-Droid.
|
||
android.uniquePackageNames=true
|
||
```
|
||
|
||
- [ ] **Step 5: Bootstrap the Gradle wrapper**
|
||
|
||
This requires a host `gradle` installation. Run once:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
chmod +x gradlew
|
||
```
|
||
|
||
- [ ] **Step 6: Verify gradle wrapper works**
|
||
|
||
```bash
|
||
./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**
|
||
|
||
```bash
|
||
git add settings.gradle.kts build.gradle.kts gradle.properties gradle/
|
||
chmod +x gradlew && git add gradlew gradlew.bat
|
||
git commit -m "chore: add gradle wrapper + version catalog + root build"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: App Module Gradle Setup
|
||
|
||
**Files:**
|
||
- Create: `app/.gitignore`
|
||
- Create: `app/build.gradle.kts`
|
||
- Create: `app/proguard-rules.pro`
|
||
|
||
- [ ] **Step 1: Create `app/.gitignore`**
|
||
|
||
```
|
||
/build
|
||
```
|
||
|
||
- [ ] **Step 2: Create `app/proguard-rules.pro`**
|
||
|
||
```
|
||
# Keep Hilt-generated classes
|
||
-keep class dagger.hilt.** { *; }
|
||
-keep class * extends dagger.hilt.android.HiltAndroidApp
|
||
|
||
# Compose Compiler may keep its own; defaults are fine
|
||
-dontwarn org.jetbrains.annotations.**
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/build.gradle.kts`**
|
||
|
||
```kotlin
|
||
plugins {
|
||
alias(libs.plugins.android.application)
|
||
alias(libs.plugins.kotlin.android)
|
||
alias(libs.plugins.kotlin.compose)
|
||
alias(libs.plugins.ksp)
|
||
alias(libs.plugins.hilt)
|
||
}
|
||
|
||
android {
|
||
namespace = "de.jeanlucmakiola.calendula"
|
||
compileSdk = 36
|
||
|
||
defaultConfig {
|
||
applicationId = "de.jeanlucmakiola.calendula"
|
||
minSdk = 29
|
||
targetSdk = 36
|
||
versionCode = 1
|
||
versionName = "0.1.0"
|
||
|
||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||
vectorDrawables { useSupportLibrary = true }
|
||
}
|
||
|
||
buildTypes {
|
||
release {
|
||
isMinifyEnabled = true
|
||
isShrinkResources = true
|
||
proguardFiles(
|
||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||
"proguard-rules.pro"
|
||
)
|
||
}
|
||
debug {
|
||
applicationIdSuffix = ".debug"
|
||
isMinifyEnabled = false
|
||
}
|
||
}
|
||
|
||
compileOptions {
|
||
sourceCompatibility = JavaVersion.VERSION_17
|
||
targetCompatibility = JavaVersion.VERSION_17
|
||
}
|
||
kotlinOptions {
|
||
jvmTarget = "17"
|
||
}
|
||
|
||
buildFeatures {
|
||
compose = true
|
||
}
|
||
|
||
packaging {
|
||
resources {
|
||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||
}
|
||
}
|
||
|
||
testOptions {
|
||
unitTests.all { test ->
|
||
test.useJUnitPlatform()
|
||
}
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
implementation(libs.androidx.core.ktx)
|
||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||
implementation(libs.androidx.activity.compose)
|
||
|
||
implementation(platform(libs.androidx.compose.bom))
|
||
implementation(libs.androidx.ui)
|
||
implementation(libs.androidx.ui.graphics)
|
||
implementation(libs.androidx.ui.tooling.preview)
|
||
implementation(libs.androidx.material3)
|
||
|
||
implementation(libs.hilt.android)
|
||
ksp(libs.hilt.compiler)
|
||
|
||
implementation(libs.androidx.datastore.preferences)
|
||
|
||
debugImplementation(libs.androidx.ui.tooling)
|
||
debugImplementation(libs.androidx.ui.test.manifest)
|
||
|
||
testImplementation(libs.junit.jupiter.api)
|
||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||
testRuntimeOnly(libs.junit.platform.launcher)
|
||
testImplementation(libs.truth)
|
||
|
||
androidTestImplementation(libs.androidx.junit)
|
||
androidTestImplementation(libs.androidx.espresso.core)
|
||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Sync gradle**
|
||
|
||
```bash
|
||
./gradlew help
|
||
```
|
||
|
||
Expected: Build succeeds. No project setup errors. Some dependency download output is normal on first run.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/.gitignore app/build.gradle.kts app/proguard-rules.pro
|
||
git commit -m "chore: configure :app module gradle build"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Android Manifest + Base Resources
|
||
|
||
**Files:**
|
||
- Create: `app/src/main/AndroidManifest.xml`
|
||
- Create: `app/src/main/res/values/strings.xml`
|
||
- Create: `app/src/main/res/values-de/strings.xml`
|
||
- Create: `app/src/main/res/values/colors.xml`
|
||
- Create: `app/src/main/res/values/themes.xml`
|
||
|
||
- [ ] **Step 1: Create `app/src/main/AndroidManifest.xml`**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||
xmlns:tools="http://schemas.android.com/tools">
|
||
|
||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||
|
||
<application
|
||
android:name=".CalendulaApp"
|
||
android:allowBackup="true"
|
||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||
android:fullBackupContent="@xml/backup_rules"
|
||
android:icon="@mipmap/ic_launcher"
|
||
android:label="@string/app_name"
|
||
android:roundIcon="@mipmap/ic_launcher_round"
|
||
android:supportsRtl="true"
|
||
android:theme="@style/Theme.Calendula"
|
||
tools:targetApi="35">
|
||
<activity
|
||
android:name=".MainActivity"
|
||
android:exported="true"
|
||
android:label="@string/app_name"
|
||
android:theme="@style/Theme.Calendula">
|
||
<intent-filter>
|
||
<action android:name="android.intent.action.MAIN" />
|
||
<category android:name="android.intent.category.LAUNCHER" />
|
||
</intent-filter>
|
||
</activity>
|
||
</application>
|
||
|
||
</manifest>
|
||
```
|
||
|
||
- [ ] **Step 2: Create backup-rules placeholder XML files**
|
||
|
||
Create `app/src/main/res/xml/backup_rules.xml`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<full-backup-content>
|
||
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
|
||
</full-backup-content>
|
||
```
|
||
|
||
Create `app/src/main/res/xml/data_extraction_rules.xml`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<data-extraction-rules>
|
||
<cloud-backup>
|
||
<!-- Allow DataStore backup, exclude nothing extra. -->
|
||
</cloud-backup>
|
||
<device-transfer>
|
||
</device-transfer>
|
||
</data-extraction-rules>
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/src/main/res/values/strings.xml` (English master)**
|
||
|
||
```xml
|
||
<resources>
|
||
<string name="app_name">Calendula</string>
|
||
<string name="app_tagline">A modern calendar.</string>
|
||
|
||
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||
<string name="state_loading">Loading…</string>
|
||
<string name="state_retry">Retry</string>
|
||
<string name="state_failure_unknown">Something went wrong.</string>
|
||
<string name="state_failure_permission">Calendar access is required.</string>
|
||
<string name="state_failure_permission_action">Grant access</string>
|
||
<string name="state_failure_no_calendars">No calendars configured.</string>
|
||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||
<string name="state_failure_provider">Could not read the calendar.</string>
|
||
</resources>
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/src/main/res/values-de/strings.xml`**
|
||
|
||
```xml
|
||
<resources>
|
||
<string name="app_name">Calendula</string>
|
||
<string name="app_tagline">Ein moderner Kalender.</string>
|
||
|
||
<string name="state_loading">Lädt…</string>
|
||
<string name="state_retry">Erneut versuchen</string>
|
||
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
||
<string name="state_failure_permission">Zugriff auf den Kalender wird benötigt.</string>
|
||
<string name="state_failure_permission_action">Zugriff erlauben</string>
|
||
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
||
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||
</resources>
|
||
```
|
||
|
||
- [ ] **Step 5: Create `app/src/main/res/values/colors.xml`**
|
||
|
||
```xml
|
||
<resources>
|
||
<!-- Seed color: desaturated slate blue-gray. Material 3 derives Light & Dark schemes from this. -->
|
||
<color name="seed">#FF5C6B7A</color>
|
||
<!-- Adaptive icon background -->
|
||
<color name="ic_launcher_background">#FF5C6B7A</color>
|
||
</resources>
|
||
```
|
||
|
||
- [ ] **Step 6: Create `app/src/main/res/values/themes.xml`** (minimal stub — Compose handles real theming)
|
||
|
||
```xml
|
||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||
<style name="Theme.Calendula" parent="android:Theme.Material.Light.NoActionBar">
|
||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||
<item name="android:windowLightStatusBar">true</item>
|
||
</style>
|
||
</resources>
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/src/main/AndroidManifest.xml app/src/main/res/
|
||
git commit -m "feat: add android manifest, strings (DE+EN), colors, base theme"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Adaptive Launcher Icon — Static "1" on Slate Squircle
|
||
|
||
**Files:**
|
||
- Create: `app/src/main/res/drawable/ic_launcher_background.xml`
|
||
- Create: `app/src/main/res/drawable/ic_launcher_foreground.xml`
|
||
- Create: `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml`
|
||
- Create: `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml`
|
||
|
||
The adaptive icon is 108dp × 108dp, with the inner 72dp safe zone visible (mask applied by launcher). Foreground is centered on a 432dp viewport when designing in vector terms (factor 4× over 108dp).
|
||
|
||
- [ ] **Step 1: Create `app/src/main/res/drawable/ic_launcher_background.xml`**
|
||
|
||
A solid slate color background:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||
android:shape="rectangle">
|
||
<solid android:color="@color/ic_launcher_background" />
|
||
</shape>
|
||
```
|
||
|
||
- [ ] **Step 2: Create `app/src/main/res/drawable/ic_launcher_foreground.xml`**
|
||
|
||
A bold "1" centered in the 108dp × 108dp viewport, sized to stay inside the 66dp inner safe zone (launcher masks may crop further). The "1" is drawn as a single-color path with a slight serif foot.
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||
android:width="108dp"
|
||
android:height="108dp"
|
||
android:viewportWidth="108"
|
||
android:viewportHeight="108">
|
||
<!--
|
||
Stylized "1" centered in the 108×108 viewport.
|
||
Reference: kalendae (the first day of the month) — etymological root
|
||
of both "Calendar" and "Calendula".
|
||
Color is off-white for high contrast on the slate background.
|
||
-->
|
||
<path
|
||
android:fillColor="#FFFAF6F0"
|
||
android:pathData="M51.5,38 L51.5,38 C49.5,40 46.5,41.5 43,42.5 L43,49 C46.2,48.2 49,47 51.5,45.5 L51.5,72 L43.5,72 L43.5,76 L65.5,76 L65.5,72 L57.5,72 L57.5,38 Z" />
|
||
</vector>
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml`**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||
<background android:drawable="@drawable/ic_launcher_background" />
|
||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||
</adaptive-icon>
|
||
```
|
||
|
||
- [ ] **Step 4: Create `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml`**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8"?>
|
||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||
<background android:drawable="@drawable/ic_launcher_background" />
|
||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||
</adaptive-icon>
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build still works**
|
||
|
||
```bash
|
||
./gradlew assembleDebug
|
||
```
|
||
|
||
Expected: BUILD SUCCESSFUL. The APK is in `app/build/outputs/apk/debug/`.
|
||
|
||
> Note: The "1" path above is a simple approximation. The icon will be visually refined during the later UI-design iteration; this is the "ships" version that establishes shape, color, and meaning correctly.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/src/main/res/drawable/ic_launcher_background.xml \
|
||
app/src/main/res/drawable/ic_launcher_foreground.xml \
|
||
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml \
|
||
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
|
||
git commit -m "feat: adaptive launcher icon — '1' on slate squircle (kalendae)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Compose Theme (Color, Theme, Typography) + Unit Test
|
||
|
||
**Files:**
|
||
- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt`
|
||
- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt`
|
||
- Create: `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt`
|
||
- Create: `app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt`
|
||
|
||
- [ ] **Step 1: Write the failing color scheme test**
|
||
|
||
Create `app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt`:
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula.ui.theme
|
||
|
||
import androidx.compose.ui.graphics.Color
|
||
import com.google.common.truth.Truth.assertThat
|
||
import org.junit.jupiter.api.Test
|
||
|
||
class ColorSchemeTest {
|
||
|
||
@Test
|
||
fun `seed color matches design spec slate`() {
|
||
// The seed color must remain stable - the design is anchored to it.
|
||
// Change this only if the spec is updated.
|
||
assertThat(CalendulaSeed).isEqualTo(Color(0xFF5C6B7A))
|
||
}
|
||
|
||
@Test
|
||
fun `light fallback scheme uses seed as primary derivation source`() {
|
||
val scheme = CalendulaLightFallback
|
||
// Primary should be a recognizable derivative of the seed (not neutral gray)
|
||
assertThat(scheme.primary).isNotEqualTo(Color.Black)
|
||
assertThat(scheme.primary).isNotEqualTo(Color.White)
|
||
}
|
||
|
||
@Test
|
||
fun `dark fallback scheme differs from light`() {
|
||
assertThat(CalendulaDarkFallback.background).isNotEqualTo(CalendulaLightFallback.background)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the failing test**
|
||
|
||
```bash
|
||
./gradlew :app:testDebugUnitTest --tests "de.jeanlucmakiola.calendula.ui.theme.ColorSchemeTest"
|
||
```
|
||
|
||
Expected: FAIL with `Unresolved reference: CalendulaSeed` (and similar). Good — the test drives the API.
|
||
|
||
- [ ] **Step 3: Create `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt`**
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula.ui.theme
|
||
|
||
import androidx.compose.material3.darkColorScheme
|
||
import androidx.compose.material3.lightColorScheme
|
||
import androidx.compose.ui.graphics.Color
|
||
|
||
/**
|
||
* Seed color anchoring the entire palette. See spec section 12.
|
||
* Desaturated slate blue-gray; distinct from HouseHoldKeaper's sage.
|
||
*/
|
||
val CalendulaSeed: Color = Color(0xFF5C6B7A)
|
||
|
||
/**
|
||
* Fallback light scheme used on devices that don't support dynamic color
|
||
* (API < 31) or when the user disables it. The real palette is derived by
|
||
* Material's tonal-palette generator from CalendulaSeed at runtime; these
|
||
* constants are a hand-picked subset that approximates that result.
|
||
*/
|
||
val CalendulaLightFallback = lightColorScheme(
|
||
primary = Color(0xFF3B5364),
|
||
onPrimary = Color(0xFFFFFFFF),
|
||
primaryContainer = Color(0xFFCBE6FA),
|
||
onPrimaryContainer = Color(0xFF001E2E),
|
||
secondary = Color(0xFF526070),
|
||
onSecondary = Color(0xFFFFFFFF),
|
||
background = Color(0xFFFBFCFE),
|
||
onBackground = Color(0xFF191C1F),
|
||
surface = Color(0xFFFBFCFE),
|
||
onSurface = Color(0xFF191C1F),
|
||
)
|
||
|
||
val CalendulaDarkFallback = darkColorScheme(
|
||
primary = Color(0xFFA3CBE2),
|
||
onPrimary = Color(0xFF003348),
|
||
primaryContainer = Color(0xFF21495F),
|
||
onPrimaryContainer = Color(0xFFCBE6FA),
|
||
secondary = Color(0xFFB9C8DA),
|
||
onSecondary = Color(0xFF243240),
|
||
background = Color(0xFF101316),
|
||
onBackground = Color(0xFFE1E3E6),
|
||
surface = Color(0xFF101316),
|
||
onSurface = Color(0xFFE1E3E6),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
```bash
|
||
./gradlew :app:testDebugUnitTest --tests "de.jeanlucmakiola.calendula.ui.theme.ColorSchemeTest"
|
||
```
|
||
|
||
Expected: 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Create `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt`**
|
||
|
||
For V1 we use Compose Material 3 defaults. Refinement (custom font, expressive type scale) happens later in the UI-design iteration.
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula.ui.theme
|
||
|
||
import androidx.compose.material3.Typography
|
||
|
||
/**
|
||
* Default Material 3 Expressive typography. Custom font + tuned scale will
|
||
* land in a later UI-design iteration; the defaults are intentional for V1
|
||
* scaffolding to keep the foundation lean.
|
||
*/
|
||
val CalendulaTypography = Typography()
|
||
```
|
||
|
||
- [ ] **Step 6: Create `app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt`**
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula.ui.theme
|
||
|
||
import android.os.Build
|
||
import androidx.compose.foundation.isSystemInDarkTheme
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.dynamicDarkColorScheme
|
||
import androidx.compose.material3.dynamicLightColorScheme
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.ui.platform.LocalContext
|
||
|
||
/**
|
||
* App theme. Honors:
|
||
* - System light/dark.
|
||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||
* derived from [CalendulaSeed].
|
||
*
|
||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||
* but the V1 foundation just follows the system.
|
||
*/
|
||
@Composable
|
||
fun CalendulaTheme(
|
||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||
dynamicColor: Boolean = true,
|
||
content: @Composable () -> Unit,
|
||
) {
|
||
val colorScheme = when {
|
||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||
val ctx = LocalContext.current
|
||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
||
}
|
||
darkTheme -> CalendulaDarkFallback
|
||
else -> CalendulaLightFallback
|
||
}
|
||
|
||
MaterialTheme(
|
||
colorScheme = colorScheme,
|
||
typography = CalendulaTypography,
|
||
content = content,
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify everything builds**
|
||
|
||
```bash
|
||
./gradlew assembleDebug
|
||
```
|
||
|
||
Expected: BUILD SUCCESSFUL.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/ \
|
||
app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/
|
||
git commit -m "feat: M3 Expressive theme with dynamic color + fallback scheme from slate seed"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Application Class (Hilt entry point) + MainActivity
|
||
|
||
**Files:**
|
||
- Create: `app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt`
|
||
- Create: `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt`
|
||
|
||
- [ ] **Step 1: Create `app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt`**
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula
|
||
|
||
import android.app.Application
|
||
import dagger.hilt.android.HiltAndroidApp
|
||
|
||
/**
|
||
* Application entry point. Registered as android:name=".CalendulaApp"
|
||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
||
*/
|
||
@HiltAndroidApp
|
||
class CalendulaApp : Application()
|
||
```
|
||
|
||
- [ ] **Step 2: Create `app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt`**
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula
|
||
|
||
import android.os.Bundle
|
||
import androidx.activity.ComponentActivity
|
||
import androidx.activity.compose.setContent
|
||
import androidx.activity.enableEdgeToEdge
|
||
import androidx.compose.foundation.layout.Arrangement
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.padding
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.Scaffold
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.res.stringResource
|
||
import androidx.compose.ui.tooling.preview.Preview
|
||
import dagger.hilt.android.AndroidEntryPoint
|
||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||
|
||
@AndroidEntryPoint
|
||
class MainActivity : ComponentActivity() {
|
||
override fun onCreate(savedInstanceState: Bundle?) {
|
||
super.onCreate(savedInstanceState)
|
||
enableEdgeToEdge()
|
||
setContent {
|
||
CalendulaTheme {
|
||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun PlaceholderScreen(modifier: Modifier = Modifier) {
|
||
Column(
|
||
modifier = modifier.fillMaxSize(),
|
||
verticalArrangement = Arrangement.Center,
|
||
horizontalAlignment = Alignment.CenterHorizontally,
|
||
) {
|
||
Text(
|
||
text = stringResource(R.string.app_name),
|
||
style = MaterialTheme.typography.displayMedium,
|
||
)
|
||
Text(
|
||
text = stringResource(R.string.app_tagline),
|
||
style = MaterialTheme.typography.bodyLarge,
|
||
)
|
||
}
|
||
}
|
||
|
||
@Preview(showBackground = true)
|
||
@Composable
|
||
private fun PlaceholderPreview() {
|
||
CalendulaTheme { PlaceholderScreen() }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build & install on a device or emulator (manual sanity check)**
|
||
|
||
```bash
|
||
./gradlew installDebug
|
||
```
|
||
|
||
Expected: APK installs as "Calendula" with the slate "1" icon. Launching it shows "Calendula" + "A modern calendar." centered on themed background.
|
||
|
||
> If you don't have a device handy, just `./gradlew assembleDebug` and move on; the UI test in Task 10 covers behavior.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt \
|
||
app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
|
||
git commit -m "feat: scaffold CalendulaApp + MainActivity with themed placeholder"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Smoke UI Test
|
||
|
||
**Files:**
|
||
- Create: `app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt`
|
||
|
||
- [ ] **Step 1: Write the failing UI test**
|
||
|
||
```kotlin
|
||
package de.jeanlucmakiola.calendula
|
||
|
||
import androidx.compose.ui.test.assertIsDisplayed
|
||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||
import androidx.compose.ui.test.onNodeWithText
|
||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||
import org.junit.Rule
|
||
import org.junit.Test
|
||
import org.junit.runner.RunWith
|
||
|
||
@RunWith(AndroidJUnit4::class)
|
||
class MainActivitySmokeTest {
|
||
|
||
@get:Rule
|
||
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||
|
||
@Test
|
||
fun appName_isDisplayed_onLaunch() {
|
||
composeTestRule.onNodeWithText("Calendula").assertIsDisplayed()
|
||
}
|
||
|
||
@Test
|
||
fun tagline_isDisplayed_onLaunch() {
|
||
composeTestRule.onNodeWithText("A modern calendar.").assertIsDisplayed()
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the test runs (requires emulator/device)**
|
||
|
||
```bash
|
||
./gradlew :app:connectedDebugAndroidTest
|
||
```
|
||
|
||
Expected: 2 tests PASS. If no emulator is running, this step is informational only — the CI emulator runs it instead.
|
||
|
||
> If running locally without an emulator: skip `connectedDebugAndroidTest` for now; CI will catch failures.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/src/androidTest/java/de/jeanlucmakiola/calendula/MainActivitySmokeTest.kt
|
||
git commit -m "test: add UI smoke test for MainActivity placeholder"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: CI Workflow (`.gitea/workflows/ci.yaml`)
|
||
|
||
**Files:**
|
||
- Create: `.gitea/workflows/ci.yaml`
|
||
|
||
- [ ] **Step 1: Create `.gitea/workflows/ci.yaml`**
|
||
|
||
```yaml
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches:
|
||
- '**'
|
||
tags-ignore:
|
||
- '**'
|
||
pull_request:
|
||
|
||
jobs:
|
||
ci:
|
||
runs-on: docker
|
||
env:
|
||
ANDROID_HOME: /opt/android-sdk
|
||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Setup Java
|
||
uses: actions/setup-java@v4
|
||
with:
|
||
distribution: 'zulu'
|
||
java-version: '17'
|
||
|
||
- name: Setup Android SDK
|
||
uses: android-actions/setup-android@v3
|
||
|
||
- name: Install Android SDK packages
|
||
run: |
|
||
sdkmanager --licenses >/dev/null <<'EOF'
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
EOF
|
||
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
|
||
|
||
- name: Install jq
|
||
run: |
|
||
set -e
|
||
SUDO=""
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
SUDO="sudo"
|
||
fi
|
||
if command -v apt-get >/dev/null 2>&1; then
|
||
$SUDO apt-get update
|
||
$SUDO apt-get install -y jq
|
||
elif command -v apk >/dev/null 2>&1; then
|
||
$SUDO apk add --no-cache jq
|
||
fi
|
||
|
||
- name: Setup Gradle cache
|
||
uses: actions/cache@v4
|
||
with:
|
||
path: |
|
||
~/.gradle/caches
|
||
~/.gradle/wrapper
|
||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-gradle-
|
||
|
||
- name: Grant execute permission for gradlew
|
||
run: chmod +x ./gradlew
|
||
|
||
- name: Lint
|
||
run: ./gradlew lint --no-daemon
|
||
|
||
- name: Unit tests
|
||
run: ./gradlew test --no-daemon
|
||
|
||
- name: Assemble debug APK
|
||
run: ./gradlew assembleDebug --no-daemon
|
||
|
||
- name: Trivy filesystem scan
|
||
run: |
|
||
set -e
|
||
SUDO=""
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
SUDO="sudo"
|
||
fi
|
||
if command -v apt-get >/dev/null 2>&1; then
|
||
$SUDO apt-get update
|
||
$SUDO apt-get install -y wget apt-transport-https gnupg lsb-release
|
||
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
|
||
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
|
||
$SUDO apt-get update
|
||
$SUDO apt-get install -y trivy
|
||
fi
|
||
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
|
||
continue-on-error: true
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add .gitea/workflows/ci.yaml
|
||
git commit -m "ci: add gitea CI workflow (lint, test, assemble, trivy)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Release Workflow (`.gitea/workflows/release.yaml`)
|
||
|
||
**Files:**
|
||
- Create: `.gitea/workflows/release.yaml`
|
||
|
||
This adapts the HouseHoldKeaper release workflow for Gradle/Android. Keystore secrets (`KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`, `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS`) must be configured in Gitea repo settings before the first tag is pushed.
|
||
|
||
- [ ] **Step 1: Create `.gitea/workflows/release.yaml`**
|
||
|
||
```yaml
|
||
name: Build and Release to F-Droid
|
||
|
||
on:
|
||
push:
|
||
tags:
|
||
- '*'
|
||
workflow_dispatch:
|
||
|
||
jobs:
|
||
ci:
|
||
runs-on: docker
|
||
env:
|
||
ANDROID_HOME: /opt/android-sdk
|
||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Setup Java
|
||
uses: actions/setup-java@v4
|
||
with:
|
||
distribution: 'zulu'
|
||
java-version: '17'
|
||
|
||
- name: Setup Android SDK
|
||
uses: android-actions/setup-android@v3
|
||
|
||
- name: Install Android SDK packages
|
||
run: |
|
||
sdkmanager --licenses >/dev/null <<'EOF'
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
EOF
|
||
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
|
||
|
||
- name: Grant execute permission for gradlew
|
||
run: chmod +x ./gradlew
|
||
|
||
- name: Lint + tests + debug build (sanity)
|
||
run: ./gradlew lint test assembleDebug --no-daemon
|
||
|
||
build-and-deploy:
|
||
needs: ci
|
||
runs-on: docker
|
||
env:
|
||
ANDROID_HOME: /opt/android-sdk
|
||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Setup Java
|
||
uses: actions/setup-java@v4
|
||
with:
|
||
distribution: 'zulu'
|
||
java-version: '17'
|
||
|
||
- name: Setup Android SDK
|
||
uses: android-actions/setup-android@v3
|
||
|
||
- name: Install Android SDK packages
|
||
run: |
|
||
sdkmanager --licenses >/dev/null <<'EOF'
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
y
|
||
EOF
|
||
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
|
||
|
||
- name: Install jq
|
||
run: |
|
||
set -e
|
||
SUDO=""
|
||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||
if command -v apt-get >/dev/null 2>&1; then
|
||
$SUDO apt-get update
|
||
$SUDO apt-get install -y jq
|
||
elif command -v apk >/dev/null 2>&1; then
|
||
$SUDO apk add --no-cache jq
|
||
fi
|
||
|
||
- name: Set version from git tag
|
||
run: |
|
||
set -e
|
||
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||
VERSION="${RAW_TAG#v}"
|
||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
|
||
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
|
||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||
|
||
- name: Setup Android Keystore
|
||
env:
|
||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||
run: |
|
||
mkdir -p app
|
||
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
||
cat > key.properties <<EOF
|
||
storePassword=$KEY_PASSWORD
|
||
keyPassword=$KEY_PASSWORD
|
||
keyAlias=$KEY_ALIAS
|
||
storeFile=upload-keystore.jks
|
||
EOF
|
||
|
||
- name: Grant execute permission for gradlew
|
||
run: chmod +x ./gradlew
|
||
|
||
- name: Build release APK
|
||
run: ./gradlew assembleRelease --no-daemon
|
||
|
||
- name: Setup F-Droid Server Tools
|
||
run: |
|
||
SUDO=""
|
||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||
$SUDO apt-get update
|
||
$SUDO apt-get install -y sshpass python3-pip
|
||
pip3 install --break-system-packages --upgrade fdroidserver
|
||
|
||
- name: Initialize or fetch F-Droid Repository
|
||
env:
|
||
HOST: ${{ secrets.HETZNER_HOST }}
|
||
USER: ${{ secrets.HETZNER_USER }}
|
||
PASS: ${{ secrets.HETZNER_PASS }}
|
||
run: |
|
||
mkdir -p fdroid
|
||
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
|
||
-mkdir dev
|
||
-mkdir dev/fdroid
|
||
-mkdir dev/fdroid/repo
|
||
SFTP
|
||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
|
||
|
||
- name: Ensure F-Droid repo signing key and icon
|
||
run: |
|
||
cd fdroid
|
||
mkdir -p repo/icons
|
||
if [ ! -f repo/icons/icon.png ]; then
|
||
# Fallback: copy a placeholder; until a PNG icon ships,
|
||
# F-Droid uses a generic icon. Real icon comes when we have a PNG export.
|
||
true
|
||
fi
|
||
if [ ! -f keystore.p12 ]; then
|
||
fdroid update --create-key
|
||
fi
|
||
|
||
- name: Copy new APK to repo
|
||
run: |
|
||
set -e
|
||
mkdir -p fdroid/repo
|
||
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
|
||
if [ -z "$SAFE_REF_NAME" ]; then
|
||
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
|
||
fi
|
||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
|
||
|
||
- name: Copy metadata to F-Droid repo
|
||
run: |
|
||
mkdir -p fdroid/metadata
|
||
cp -r fdroid-metadata/* fdroid/metadata/
|
||
|
||
- name: Generate F-Droid Index
|
||
run: |
|
||
cd fdroid
|
||
fdroid update -c
|
||
|
||
- name: Upload Repo to Hetzner
|
||
env:
|
||
HOST: ${{ secrets.HETZNER_HOST }}
|
||
USER: ${{ secrets.HETZNER_USER }}
|
||
PASS: ${{ secrets.HETZNER_PASS }}
|
||
run: |
|
||
set -euo pipefail
|
||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
||
-mkdir dev
|
||
-mkdir dev/fdroid
|
||
-mkdir dev/fdroid/repo
|
||
SFTP
|
||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
|
||
```
|
||
|
||
> Note: The release workflow assumes `app/build.gradle.kts` will pick up signing config from a `key.properties` file at the project root. We add that wiring in Task 13.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add .gitea/workflows/release.yaml
|
||
git commit -m "ci: add gitea release workflow with F-Droid pipeline"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Wire Signing Config into App Gradle
|
||
|
||
**Files:**
|
||
- Modify: `app/build.gradle.kts`
|
||
|
||
The release workflow drops a `key.properties` at project root and a `upload-keystore.jks` in `app/`. Make Gradle read these.
|
||
|
||
- [ ] **Step 1: Replace the contents of `app/build.gradle.kts`**
|
||
|
||
```kotlin
|
||
import java.util.Properties
|
||
import java.io.FileInputStream
|
||
|
||
plugins {
|
||
alias(libs.plugins.android.application)
|
||
alias(libs.plugins.kotlin.android)
|
||
alias(libs.plugins.kotlin.compose)
|
||
alias(libs.plugins.ksp)
|
||
alias(libs.plugins.hilt)
|
||
}
|
||
|
||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||
val keystoreProperties = Properties().apply {
|
||
if (keystorePropertiesFile.exists()) {
|
||
load(FileInputStream(keystorePropertiesFile))
|
||
}
|
||
}
|
||
|
||
android {
|
||
namespace = "de.jeanlucmakiola.calendula"
|
||
compileSdk = 36
|
||
|
||
defaultConfig {
|
||
applicationId = "de.jeanlucmakiola.calendula"
|
||
minSdk = 29
|
||
targetSdk = 36
|
||
versionCode = 1
|
||
versionName = "0.1.0"
|
||
|
||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||
vectorDrawables { useSupportLibrary = true }
|
||
}
|
||
|
||
signingConfigs {
|
||
if (keystorePropertiesFile.exists()) {
|
||
create("release") {
|
||
keyAlias = keystoreProperties["keyAlias"] as String
|
||
keyPassword = keystoreProperties["keyPassword"] as String
|
||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||
storePassword = keystoreProperties["storePassword"] as String
|
||
}
|
||
}
|
||
}
|
||
|
||
buildTypes {
|
||
release {
|
||
isMinifyEnabled = true
|
||
isShrinkResources = true
|
||
proguardFiles(
|
||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||
"proguard-rules.pro"
|
||
)
|
||
if (keystorePropertiesFile.exists()) {
|
||
signingConfig = signingConfigs.getByName("release")
|
||
}
|
||
}
|
||
debug {
|
||
applicationIdSuffix = ".debug"
|
||
isMinifyEnabled = false
|
||
}
|
||
}
|
||
|
||
compileOptions {
|
||
sourceCompatibility = JavaVersion.VERSION_17
|
||
targetCompatibility = JavaVersion.VERSION_17
|
||
}
|
||
kotlinOptions {
|
||
jvmTarget = "17"
|
||
}
|
||
|
||
buildFeatures {
|
||
compose = true
|
||
}
|
||
|
||
packaging {
|
||
resources {
|
||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||
}
|
||
}
|
||
|
||
testOptions {
|
||
unitTests.all { test ->
|
||
test.useJUnitPlatform()
|
||
}
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
implementation(libs.androidx.core.ktx)
|
||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||
implementation(libs.androidx.activity.compose)
|
||
|
||
implementation(platform(libs.androidx.compose.bom))
|
||
implementation(libs.androidx.ui)
|
||
implementation(libs.androidx.ui.graphics)
|
||
implementation(libs.androidx.ui.tooling.preview)
|
||
implementation(libs.androidx.material3)
|
||
|
||
implementation(libs.hilt.android)
|
||
ksp(libs.hilt.compiler)
|
||
|
||
implementation(libs.androidx.datastore.preferences)
|
||
|
||
debugImplementation(libs.androidx.ui.tooling)
|
||
debugImplementation(libs.androidx.ui.test.manifest)
|
||
|
||
testImplementation(libs.junit.jupiter.api)
|
||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||
testRuntimeOnly(libs.junit.platform.launcher)
|
||
testImplementation(libs.truth)
|
||
|
||
androidTestImplementation(libs.androidx.junit)
|
||
androidTestImplementation(libs.androidx.espresso.core)
|
||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify debug build still works (without keystore)**
|
||
|
||
```bash
|
||
./gradlew assembleDebug
|
||
```
|
||
|
||
Expected: BUILD SUCCESSFUL. The release block silently skips signing config when no `key.properties` is present.
|
||
|
||
- [ ] **Step 3: Verify release build skips signing locally**
|
||
|
||
```bash
|
||
./gradlew assembleRelease
|
||
```
|
||
|
||
Expected: BUILD SUCCESSFUL with a warning that the release APK is unsigned (or signed with debug key) — this is fine; only CI signs releases.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/build.gradle.kts
|
||
git commit -m "build: wire signing config from key.properties when present"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Final Verification & CHANGELOG Update
|
||
|
||
- [ ] **Step 1: Run the full local verification**
|
||
|
||
```bash
|
||
./gradlew lint test assembleDebug
|
||
```
|
||
|
||
Expected: All three steps complete with BUILD SUCCESSFUL.
|
||
|
||
- [ ] **Step 2: Update `CHANGELOG.md`**
|
||
|
||
Replace the existing `[Unreleased]` block with explicit shipped items for v0.1.0 and a new (empty) `[Unreleased]` placeholder. Final file content:
|
||
|
||
```markdown
|
||
# Changelog
|
||
|
||
All notable changes to this project will be documented in this file.
|
||
|
||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
## [Unreleased]
|
||
|
||
## [0.1.0] — 2026-06-08
|
||
|
||
### Added
|
||
- Initial project scaffold (Gradle Kotlin DSL, Version Catalog, Hilt, DataStore)
|
||
- Material 3 Expressive theme with Dynamic Color (API 31+) and slate-derived fallback
|
||
- Adaptive launcher icon — stylized "1" on slate squircle (references *kalendae*)
|
||
- German + English localization infrastructure
|
||
- Permission declaration for `READ_CALENDAR` (no UI flow yet — that's Plan 02)
|
||
- Gitea CI workflow: lint, unit tests, debug build, Trivy scan
|
||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||
- `.planning/` project-tracking documents
|
||
```
|
||
|
||
- [ ] **Step 3: Update `.planning/STATE.md`**
|
||
|
||
```markdown
|
||
# Calendula — Current State
|
||
|
||
*Last updated: 2026-06-08*
|
||
|
||
## Status
|
||
|
||
**Milestone:** v0.1 — Foundation & CI
|
||
**Phase:** Plan 01 complete; ready to start Plan 02
|
||
|
||
## Progress
|
||
|
||
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
|
||
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
||
- [x] Plan 01 written and executed (`docs/superpowers/plans/2026-06-08-01-foundation.md`)
|
||
- [x] Foundation lands: theme, icon, i18n, Hilt, DataStore, CI green
|
||
- [ ] Plan 02 written (Data Layer & Permission Flow)
|
||
|
||
## Next
|
||
|
||
1. Write Plan 02: Data Layer & Permission Flow
|
||
2. Execute Plan 02
|
||
3. Iterate on UI design (mockups) before screens are built
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add CHANGELOG.md .planning/STATE.md
|
||
git commit -m "docs: record v0.1.0 foundation in CHANGELOG, update STATE"
|
||
```
|
||
|
||
- [ ] **Step 5: Tag v0.1.0 — Optional, only after CI passes on Gitea**
|
||
|
||
This step happens only once the repo is pushed to Gitea, CI is configured, and CI runs green on the foundation commit. Skip locally.
|
||
|
||
```bash
|
||
# After CI is green on Gitea:
|
||
git tag -a v0.1.0 -m "v0.1.0 — foundation"
|
||
git push origin v0.1.0
|
||
```
|
||
|
||
The release workflow will then build, sign, and publish to F-Droid.
|
||
|
||
---
|
||
|
||
## Verification Checklist (post-execution)
|
||
|
||
After all 14 tasks are completed, verify:
|
||
|
||
- [ ] `./gradlew lint` exits 0
|
||
- [ ] `./gradlew test` exits 0; all unit tests pass (ColorSchemeTest x3)
|
||
- [ ] `./gradlew assembleDebug` produces a working APK at `app/build/outputs/apk/debug/app-debug.apk`
|
||
- [ ] APK installs and shows "Calendula" + "A modern calendar." in the system language (DE if locale=de_DE, else EN)
|
||
- [ ] Launcher icon shows the "1" on slate squircle
|
||
- [ ] Theme respects system light/dark
|
||
- [ ] On API 31+ with a colored wallpaper, theme picks up wallpaper colors (Dynamic Color)
|
||
- [ ] CI workflow file is at `.gitea/workflows/ci.yaml`
|
||
- [ ] Release workflow file is at `.gitea/workflows/release.yaml`
|
||
- [ ] F-Droid metadata is in `fdroid-metadata/de.jeanlucmakiola.calendula/`
|
||
- [ ] `.planning/STATE.md` reflects "Plan 01 complete"
|
||
|
||
When pushing to Gitea for the first time:
|
||
|
||
- [ ] Configure repo secrets: `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`, `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS`
|
||
- [ ] First CI run is green on `main`
|
||
- [ ] Tag `v0.1.0` triggers the release workflow successfully
|
||
- [ ] F-Droid repo at `https://<your-hetzner-host>/dev/fdroid/` shows Calendula
|
||
|
||
---
|
||
|
||
## What Plan 01 Does NOT Do (deferred to subsequent plans)
|
||
|
||
- No `CalendarRepository`, no `ContentResolver` queries → Plan 02
|
||
- No permission UI flow → Plan 02
|
||
- No calendar event reading → Plan 02
|
||
- No Month / Week / Day views → Plans 03 / 04 / 05
|
||
- No Event-Detail-Sheet → Plan 06
|
||
- No Filter / Settings → Plan 07
|
||
- The "1" icon path is geometrically simple; visual refinement happens during UI-design iteration before V1 release
|
||
- Custom typography / fonts → UI-design iteration
|
||
|
||
The foundation deliberately ships small. Every subsequent plan layers one focused feature on top of a known-good base.
|