Compare commits

...

5 Commits

Author SHA1 Message Date
f990af1cb0 ci(release): make workflow_dispatch a key-rotation / re-sign path
All checks were successful
CI / ci (push) Successful in 4m34s
The release job assumed the ref is a version tag (Set version from git tag →
versionCode). A manual workflow_dispatch from a branch yielded versionCode 0
and Gradle aborted assembleRelease before the F-Droid steps ran.

Gate the tag-only steps (version, app keystore, assembleRelease, copy APK)
on refs/tags/*. On a manual dispatch the job now skips the APK build and just
re-signs the existing index with the configured repo key and re-uploads —
exactly what a repo-key rotation or recovery needs, no new release required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:13:43 +02:00
e5be5f1ae5 security(release): rotate compromised F-Droid repo key; keep key out of served tree
All checks were successful
CI / ci (push) Successful in 5m17s
The F-Droid repo signing key (keystore.p12) and its config.yml — including
the keystore passwords in cleartext — were publicly downloadable at
apps.dev.jeanlucmakiola.de/dev/fdroid/ because the release workflow uploaded
the entire fdroid/ working dir into the web-served path. The webserver has
since been locked down to repo/ only; this rotates the now-compromised key
and removes the root cause.

- release.yaml: restore the repo key + config from new CI secrets
  (FDROID_KEYSTORE_BASE64, FDROID_CONFIG_BASE64) instead of the box; upload
  ONLY repo/ so the key never re-enters the served tree.
- release.yaml: fail loudly when the repo key secrets are unset, replacing
  `fdroid update --create-key`, which silently minted a NEW repo key on a
  wiped server and would have broken every user's pinned fingerprint.
- README: publish the new repo fingerprint (C2C0…3425). Existing users must
  remove and re-add the repo.
- .gitignore: ignore *.p12 and the whole /fdroid/ working dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:01:00 +02:00
54aed73726 docs: F-Droid install guide with repo URL + fingerprint; backlog daily-driver ideas
All checks were successful
CI / ci (push) Successful in 4m30s
README gains a real install path: add the self-hosted repo
(apps.dev.jeanlucmakiola.de/dev/fdroid/repo, fingerprint inline and as an
add-repo link), search, install. Verified live against the repo index.

Roadmap gains the approved daily-driver idea backlog (unscheduled): slot-tap
create, drag & drop rescheduling, agenda view, pinch-zoom, reminder
snooze/dismiss + default reminder, duplicate event, per-event color,
.ics share/receive, app shortcuts, jump-to-date — plus the consciously
rejected list (network-dependent features, NL quick entry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:48:30 +02:00
82c3e1d605 docs: architecture tour, docs index, showcase README; ci: Gitea release per tag
All checks were successful
CI / ci (push) Successful in 4m38s
Documentation pass after the 2.0 milestone:
- docs/ARCHITECTURE.md — principles (provider as single source of truth,
  observer-driven UI, JVM-first tests, no network), layer + reminder
  mermaid diagrams, navigation (overlay/held-key, no nav lib), and the
  provider lessons (recurring-write invariants, conflict snapshots)
- docs/README.md — map of what documentation lives where, incl. the
  convention that superpowers/ plans are historical artifacts while
  .planning/ stays current
- README.md — showcase layout (centered header, badges, screenshot
  gallery from the fastlane assets, grouped features, install/build/
  architecture/roadmap sections); renders on Gitea
- .planning/{PROJECT,REQUIREMENTS,STATE}.md unstaled: read-only-V1 talk
  removed, V1/V2 checklists marked shipped, state points at v3 + the
  Locations & People go/no-go

release.yaml gains a gitea-release job: on every tag push it extracts the
tag's CHANGELOG section and creates a Gitea release with it as the notes.
No APK assets — distribution stays with the F-Droid repo. Idempotent
(skips an existing release), gated on the test job only so notes appear
even when the F-Droid upload hiccups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:35:03 +02:00
e5b523e907 docs: backlog the Locations & People ideas (contact picker, OSM autocomplete)
Some checks failed
CI / ci (push) Has been cancelled
Captured from discussion, deliberately undetailed: permission-free contact
address picker, Photon-based address autocomplete (would need INTERNET —
explicit go/no-go on the no-network promise before any work), inline
contact suggestions, attendee editing as its own future milestone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:24:43 +02:00
9 changed files with 490 additions and 97 deletions

View File

@@ -1,4 +1,4 @@
name: Build and Release to F-Droid name: Release F-Droid repo + Gitea release
on: on:
push: push:
@@ -121,7 +121,12 @@ jobs:
$SUDO apk add --no-cache jq $SUDO apk add --no-cache jq
fi fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
# not a tag) these are skipped: the job then just re-signs the existing
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK.
- name: Set version from git tag - name: Set version from git tag
if: startsWith(github.ref, 'refs/tags/')
run: | run: |
set -e set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
@@ -137,6 +142,7 @@ jobs:
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Setup Android Keystore - name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/')
env: env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -155,6 +161,7 @@ jobs:
run: chmod +x ./gradlew run: chmod +x ./gradlew
- name: Build release APK - name: Build release APK
if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools - name: Setup F-Droid Server Tools
@@ -165,29 +172,48 @@ jobs:
$SUDO apt-get install -y sshpass python3-pip $SUDO apt-get install -y sshpass python3-pip
pip3 install --break-system-packages --upgrade fdroidserver pip3 install --break-system-packages --upgrade fdroidserver
- name: Initialize or fetch F-Droid Repository - name: Fetch existing F-Droid repo from Hetzner
env: env:
HOST: ${{ secrets.HETZNER_HOST }} HOST: ${{ secrets.HETZNER_HOST }}
USER: ${{ secrets.HETZNER_USER }} USER: ${{ secrets.HETZNER_USER }}
PASS: ${{ secrets.HETZNER_PASS }} PASS: ${{ secrets.HETZNER_PASS }}
run: | run: |
set -euo pipefail
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
mkdir -p fdroid mkdir -p fdroid
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP' # Pull only the published repo/ (all apps' APKs), any per-app
-mkdir dev # metadata, and the repo icon — enough to rebuild the index without
-mkdir dev/fdroid # dropping the other apps. The signing key is deliberately NOT pulled
-mkdir dev/fdroid/repo # from the box; it comes from CI secrets in the next step so it never
SFTP # has to live in the web-served tree.
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init) sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/repo" fdroid/ 2>/dev/null || true
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/metadata" fdroid/ 2>/dev/null || true
sshpass -p "$PASS" scp $SSH_OPTS "$USER@$HOST:dev/fdroid/icon.png" fdroid/ 2>/dev/null || true
mkdir -p fdroid/repo fdroid/metadata
- name: Ensure F-Droid repo signing key and icon - name: Restore F-Droid signing key and config from secrets
env:
FDROID_KEYSTORE_BASE64: ${{ secrets.FDROID_KEYSTORE_BASE64 }}
FDROID_CONFIG_BASE64: ${{ secrets.FDROID_CONFIG_BASE64 }}
run: | run: |
cd fdroid set -euo pipefail
mkdir -p repo/icons # Fail loudly if the repo key is not configured. NEVER auto-generate
if [ ! -f keystore.p12 ]; then # one: a fresh key changes the repo fingerprint and breaks every
fdroid update --create-key # user's pinned repo. (Replaces the old `fdroid update --create-key`
# path, which silently rotated the key on a wiped server.)
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2
exit 1
fi fi
echo "$FDROID_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.p12
echo "$FDROID_CONFIG_BASE64" | base64 --decode > fdroid/config.yml
test -s fdroid/keystore.p12
test -s fdroid/config.yml
mkdir -p fdroid/repo/icons
- name: Copy new APK to repo - name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/')
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
@@ -208,7 +234,7 @@ jobs:
cd fdroid cd fdroid
fdroid update -c fdroid update -c
- name: Upload Repo to Hetzner - name: Upload repo/ to Hetzner
env: env:
HOST: ${{ secrets.HETZNER_HOST }} HOST: ${{ secrets.HETZNER_HOST }}
USER: ${{ secrets.HETZNER_USER }} USER: ${{ secrets.HETZNER_USER }}
@@ -219,6 +245,72 @@ jobs:
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP' sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
-mkdir dev -mkdir dev
-mkdir dev/fdroid -mkdir dev/fdroid
-mkdir dev/fdroid/repo
SFTP SFTP
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/" # Publish ONLY the signed repo/. keystore.p12 and config.yml never
# leave CI, so they can no longer end up in the web-served tree.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo "$USER@$HOST:dev/fdroid/"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
echo "--- release notes ---"
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
# Re-runs must not fail on an already-published release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
if [ "$STATUS" = "200" ]; then
echo "Release for $TAG already exists — skipping."
exit 0
fi
python3 - "$TAG" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d @payload.json "$API/releases")
cat response.json
if [ "$CODE" != "201" ]; then
echo "Release creation failed with HTTP $CODE"
exit 1
fi

4
.gitignore vendored
View File

@@ -40,6 +40,7 @@ captures/
# Keystore files # Keystore files
*.jks *.jks
*.keystore *.keystore
*.p12
/key.properties /key.properties
# Google Services (e.g. APIs or Firebase) # Google Services (e.g. APIs or Firebase)
@@ -50,8 +51,7 @@ google-services.json
Thumbs.db Thumbs.db
# F-Droid local artifacts (the pipeline generates them in CI) # F-Droid local artifacts (the pipeline generates them in CI)
fdroid/repo/ /fdroid/
fdroid/keystore.p12
# KSP # KSP
.ksp/ .ksp/

View File

@@ -2,11 +2,12 @@
## What This Is ## What This Is
A modern Material 3 Expressive Android calendar app, read-only V1. Lives A modern Material 3 Expressive Android calendar app. Lives entirely on top
entirely on top of Android's `CalendarContract` — any calendar synced to the of Android's `CalendarContract` — any calendar synced to the device (CalDAV
device (CalDAV via DAVx5, Google, local, WebCal, …) shows up automatically. via DAVx5, Google, local, WebCal, …) shows up automatically; creating,
The differentiator is visual: real Material 3 Expressive design that no editing, and deleting writes straight back, and reminders are delivered by
existing FOSS calendar app delivers. the app itself (Etar model). The differentiator is visual: real Material 3
Expressive design that no existing FOSS calendar app delivers.
## Core Value ## Core Value
@@ -15,8 +16,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
## Current Milestone ## Current Milestone
**v0.1 — Foundation & CI:** Buildable Android project scaffold with theme, Milestones 1 (read, v1.0) and 2 (write support, v1.1v2.0.0 incl. reminder
icon, i18n, Hilt, DataStore, green CI. delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
(power-user features) plus an undecided "Locations & People" idea backlog;
see `ROADMAP.md`.
## Stack ## Stack
@@ -26,9 +29,8 @@ Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17. with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
Read-only V1, write support V2. Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
any feature that would need one is an explicit product decision first.
Android-only (minSdk 29, targetSdk 36). No iOS.
## Naming ## Naming

View File

@@ -2,39 +2,43 @@
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md` See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
## V1 Scope (Variant "B") ## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
### Validated (shipped) - [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
- Foundation & CI infrastructure — v0.1.0 (2026-06-08)
### Active (V1)
- [x] Foundation & CI infrastructure
- [x] Data Layer over `CalendarContract` - [x] Data Layer over `CalendarContract`
- [x] Permission flow (`READ_CALENDAR`) - [x] Permission flow (`READ_CALENDAR`)
- [ ] Month view (S1) - [x] Month view (S1)
- [ ] Week view (S2) - [x] Week view (S2)
- [ ] Day view (S3) - [x] Day view (S3)
- [ ] Event Detail Sheet (S4) - [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
- [ ] Multi-Calendar Filter (M3) - [x] Multi-Calendar Filter (M3)
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope** - [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
- [ ] View-Switcher (M1) - [x] View-Switcher (M1)
- [ ] Settings screen (M4) - [x] Settings screen (M4)
- [ ] Empty / no-permission / no-calendars states - [x] Empty / no-permission / no-calendars states
- [ ] German + English localization - [x] German + English localization
- [ ] Loading/Failure/Success states per screen (architectural pattern) - [x] Loading/Failure/Success states per screen (architectural pattern)
### Out of Scope (V2+) ## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
- [x] Reminder notifications (v1.4) — **reversal of the original
"system handles reminders" assumption:** Calendula targets
sole-calendar-app users, so it posts reminder notifications itself
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
- [x] Conflict dialog on save + store polish (v2.0)
- Quick-add — **cut from scope** (the prefilled form covers it)
- Calendar switching while editing — moved to v3 backlog
### Out of Scope (V3+)
- Event create / edit / delete (V2)
- Home-screen widget - Home-screen widget
- Full-text search - Full-text search
- Quick-add
- ~~Custom notifications/reminders (system already handles these)~~ —
**reversed:** Calendula targets sole-calendar-app users, so no other app
posts reminder notifications. We post them ourselves (Etar model). Planned
for v1.4 — see `ROADMAP.md`.
- Tablet/foldable-specific layouts - Tablet/foldable-specific layouts
- Locations & People ideas (contact picker, OSM autocomplete) — see
`ROADMAP.md` idea backlog, undecided
- iOS support (Android-only by design) - iOS support (Android-only by design)
## Constraints ## Constraints

View File

@@ -117,3 +117,49 @@ Deliberately deferred (add only if needed):
consequences warning — deferred from v2.0, see above) consequences warning — deferred from v2.0, see above)
Order is indicative — community feedback after V1 may re-prioritize. Order is indicative — community feedback after V1 may re-prioritize.
## Idea backlog — Daily-driver polish (captured 2026-06-11, all approved as ideas, unscheduled)
Interaction:
- Tap/long-press an empty slot in day/week → create form prefilled with that time
- Drag & drop rescheduling in day/week (recurring drops reuse the scope dialog) — big-ticket, own slice
- Agenda view (fourth view: upcoming events grouped by day; natural widget data source)
- Pinch-to-zoom time scale in day/week
Reminders, round two:
- Snooze + dismiss actions on the notification (snooze needs an exact-alarm/WorkManager decision)
- Settings default reminder applied to new events
Event niceties:
- Duplicate event (detail action → prefilled create form)
- Per-event color (`Events.EVENT_COLOR`, OptionCard picker in the form)
- Share event as .ics + open/receive .ics into a prefilled create form (front-runs v3 ICS import)
Small delights:
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
- Jump to date (un-cut from V1 — drawer date picker)
Consciously rejected: travel time / weather / smart suggestions (network,
core-promise conflict), natural-language quick entry (high effort,
locale-fragile, prefilled form already covers fast entry).
## Idea backlog — Locations & People (captured 2026-06-11, undecided)
Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet:
- **Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
fits the privacy story. Same mechanism later for picking emails.
- **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the
INTERNET permission** — first dent in the "no network access" promise;
if built: opt-in (off by default), honest copy, configurable endpoint
for self-hosters, onboarding footnote + F-Droid copy reworded. This
trade-off is an explicit go/no-go decision before any work starts.
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
if the picker proves clunky.
- **Attendee editing / invites from contacts** — own milestone; writing
`Attendees` rows touches sync-adapter invitation behavior (Google vs
DAVx5 differ).

View File

@@ -4,13 +4,10 @@
## Status ## Status
**Milestone:** v2.0 — Write support (milestone 2, in progress) **Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device **Phase:** between milestones. Next: v3.0 (power-user features) and the
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip, go/no-go on the "Locations & People" idea backlog (`ROADMAP.md`). Docs
stale-instances split bugfix). Milestone 2 runs in four slices pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed).
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
conflict dialog, polish) is the remaining slice, v1.4 (reminder
notifications) comes first.
## Progress ## Progress
@@ -62,10 +59,22 @@ notifications) comes first.
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
the "only this event" option the "only this event" option
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
dedicated channel, tap opens detail (singleTop deep link); best-effort
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
with duplicate-reminders warning; Settings mirror. Provider only fires
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
refreshed, fastlane screenshots DE+EN captured on-device
## Next ## Next
1. v1.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER` 1. Decide the "Locations & People" go/no-go (INTERNET permission question)
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with — see `ROADMAP.md` idea backlog
default-on toggle + duplicate-reminder warning (Etar model) 2. v3.0 scoping: widget, full-text search, tablet layouts, ICS import,
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release calendar-move
3. Monitor the F-Droid build/publish for v1.1.0 v1.3.0 3. Monitor the F-Droid build/publish for the v1.4.0 / v2.0.0 tags

139
README.md
View File

@@ -1,47 +1,120 @@
# Calendula <div align="center">
A modern Material 3 Expressive calendar app for Android. <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
Calendula is named after the flower of the same name, whose name comes from <h1>Calendula</h1>
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 <p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
- Month, Week, and Day views <p>
- Full event details — attendees, reminders, recurrence, availability, and more <a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
- Create, edit, and delete events — recurring events with scoped writes <img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
(only this event / this and all following / whole series) and a simple <img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
recurrence picker <img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
- Reminder notifications, delivered by Calendula itself (tap opens the event) <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
- Multi-calendar visibility toggle </p>
- Material You Dynamic Color (Android 12+)
- Light/Dark theme follows system
- German + English UI
## Building <p>
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
</p>
Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed: </div>
Calendula is named after the flower whose name — like the word *calendar*
comes from the Latin *kalendae*, the first day of the month. It lives
entirely on top of Android's `CalendarContract`: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
## ✨ Features
**Calendar**
- Month, week, and day views with a one-tap view switcher
- Full event details — attendees and their responses, reminders, recurrence
(humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
**Editing**
- Create, edit, and delete events — including recurring events with scoped
writes: *only this event*, *this and all following*, or *the whole series*
- Recurrence picker with one-tap presets and custom rules (interval, weekday
toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing,
Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
**Reminders**
- Event reminders delivered by Calendula itself as notifications —
essential when it's your only calendar app, since Android delegates
reminder delivery to calendar apps
- Tap a reminder to land on the event
**Design & privacy**
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag
is built, signed, and published there automatically.
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
Store, …).
2. Add the repository — open this link on your phone, or paste it under
*Settings → Repositories → Add*:
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
```
<sub>Repo: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo` ·
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
3. Refresh, search for **Calendula**, install. Updates arrive like any
other F-Droid app.
Alternatively, build from source — see below.
## 🛠 Building
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
```bash ```bash
# Build debug APK ./gradlew assembleDebug # debug APK
./gradlew assembleDebug ./gradlew test # JVM unit tests
./gradlew lint # Android lint
# Run unit tests
./gradlew test
# Run lint
./gradlew lint
``` ```
If your default JDK is something other than 17, set `JAVA_HOME` explicitly: If your default JDK is not 17, set `JAVA_HOME` explicitly.
```bash ## 🏗 Architecture
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
```
## License Single-activity Compose app, layered `UI → Repository → DataSource →
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
including the recurring-write and reminder pipelines — lives in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 🗺 Roadmap
Shipped: read (v1.0), write (v1.1v2.0), reminder delivery (v1.4).
Next up: power-user features — widget, search, tablet layouts. The living
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
history in [CHANGELOG.md](CHANGELOG.md).
## 📜 License
[MIT](LICENSE) — Jean-Luc Makiola, 2026 [MIT](LICENSE) — Jean-Luc Makiola, 2026

147
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,147 @@
# Architecture
Calendula is a single-activity Jetpack Compose app layered strictly on top
of Android's calendar provider. This document is the orientation tour: the
principles, the layers, and the three pipelines that are not obvious from
the package list (recurring writes, save conflicts, reminder delivery).
## Principles
1. **`CalendarContract` is the single source of truth.** No app database,
no caching layer, no sync code. Reads query the provider; writes go
straight back to it. Sync is DAVx5's / Google's / the system's job.
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
re-queries; every screen recomposes from fresh provider state. After a
write, nothing is patched by hand — the provider notifies, the views
refresh. This also covers external changes (sync) for free.
3. **JVM-first testing.** Everything between the UI and the
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
repository tests. Instrumented tests are a last resort.
4. **No network.** The app declares no `INTERNET` permission. Anything that
would need one is an explicit, documented product decision first
(see the roadmap's idea backlog).
## Layers
```mermaid
flowchart TD
subgraph UI ["ui/ — Compose screens + ViewModels"]
Screens["Month / Week / Day\nDetail / Edit / Settings\nPermission + Reminder onboarding"]
end
subgraph Data ["data/"]
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
Rem["reminders/\nReminderAlertStore + ReminderNotifier"]
end
Provider[("CalendarContract\n(system calendar provider)")]
Screens --> Repo
Screens --> Prefs
Repo --> DS
DS --> Provider
Provider -. "ContentObserver tick" .-> Repo
Provider -. "EVENT_REMINDER broadcast" .-> Rem
Rem --> Provider
```
- **`domain/`** — pure Kotlin, no Android imports: models
(`EventInstance`, `EventDetail`, `CalendarSource`, …), the `EventForm`
with validation, `SimpleRecurrence` (RRULE parse/render for the picker),
and `EditSnapshot` (conflict detection). All JVM-tested.
- **`data/calendar/`** — the provider seam. `AndroidCalendarDataSource`
owns every `ContentResolver` call; cursor parsing lives in mappers
(`InstanceMapper`, `EventDetailMapper`, `CalendarMapper`) that read
through a `ColumnReader` abstraction so tests feed them plain maps.
`EventWriteMapper` builds dirty-checked update value sets. `TimeBridge`
converts provider epoch millis ↔ `kotlin.time.Instant`.
- **`data/reminders/`** — the notification pipeline (see below). Kept out
of `data/calendar/` because the receiver needs neither the repository
nor its flows.
- **`data/prefs/`** — DataStore-backed settings (theme, week start, form
field defaults, reminders toggle) and small state (last-used calendar).
- **`ui/`** — one package per screen, each with Screen + ViewModel +
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
drawer, transitions).
## Navigation
There is no navigation library. `MainActivity` hosts `RootScreen`, which
gates on the calendar permission and the one-time reminder onboarding, then
shows `CalendarHost`. `CalendarHost` holds the active view (month/week/day)
plus overlay state for detail, edit, and settings — full-screen overlays
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
key stays alive through the slide-out so content never flashes empty.
A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap.
## Recurring writes
The provider's invariants drive the design (learned the hard way, verified
on-device — see plan 03):
- Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows
carry `DTEND`.
- *Only this event* → insert a **modified-occurrence exception** via
`CONTENT_EXCEPTION_URI` (the provider clones the series row, so empty
optionals are written as explicit NULLs).
- *This and following* → **series split**: insert the new event first (if
that fails the original is untouched), then truncate the original's
RRULE with `UNTIL`.
- Truncation updates must send the **complete time-column set**
(`DTSTART`/`DURATION`/`RRULE`/`ALL_DAY`/`EVENT_TIMEZONE`) — the provider
regenerates cached instances only from the values carried by the update
itself; an RRULE-only update leaves stale instances behind.
- `UNTIL` is written as the local end of the previous day expressed in
UTC, so zones ahead of UTC can't leak an extra occurrence.
- All-day events are normalised to UTC midnights with an exclusive end.
## Save conflicts
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
*plus the raw Events-row times* (the form derives its times from the tapped
occurrence, so a remotely moved event would otherwise be invisible to it).
Right before writing, the event is re-read and snapshots compared: a
mismatch parks the save in an overwrite/discard dialog; a vanished event
informs and closes. Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. Fields the form cannot
write (attendees, status, reminder methods) are excluded so sync noise
can't fake a conflict.
## Reminder delivery
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app
must (the Etar model):
```mermaid
sequenceDiagram
participant P as CalendarProvider
participant R as EventReminderReceiver
participant S as ReminderAlertStore
participant N as ReminderNotifier
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported)
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now
S-->>R: due alerts
R->>N: post(alert) — one notification per alert, tag = alert id
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR
```
Posting happens before marking: a crash in between re-posts silently (same
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
notifications never return because `FIRED` rows are never re-queried.
Deliberately absent until real devices prove it necessary: own alarm
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
prompts.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI
(Gitea Actions) runs `lint test assembleDebug` on every push; release tags
additionally build, sign, and publish to the self-hosted F-Droid repo.

20
docs/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Documentation map
Where to look for what:
| Document | What it is |
|---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing |
| [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) |
| [`../.planning/ROADMAP.md`](../.planning/ROADMAP.md) | Living roadmap: shipped milestones, current scope, idea backlog |
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is, stack, naming, infrastructure |
| [`../.planning/REQUIREMENTS.md`](../.planning/REQUIREMENTS.md) | Requirement checklist per milestone |
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
| [`../fdroid-metadata/`](../fdroid-metadata/) | F-Droid/fastlane store metadata: descriptions, icon, screenshots (DE + EN) |
Conventions: plans and specs under `superpowers/` are point-in-time
artifacts of the agentic workflow that built each milestone — they get
status updates but are never rewritten. The `.planning/` files are living
documents and should stay current.