docs: architecture tour, docs index, showcase README; ci: Gitea release per tag
All checks were successful
CI / ci (push) Successful in 4m38s
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Release to F-Droid
|
name: Release — F-Droid repo + Gitea release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -222,3 +222,68 @@ jobs:
|
|||||||
-mkdir dev/fdroid/repo
|
-mkdir dev/fdroid/repo
|
||||||
SFTP
|
SFTP
|
||||||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
|
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$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
|
||||||
|
|||||||
@@ -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.1–v2.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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
122
README.md
122
README.md
@@ -1,47 +1,103 @@
|
|||||||
# 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">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||||
|
<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 (releases are
|
||||||
|
built and published automatically from version tags). 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.1–v2.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
147
docs/ARCHITECTURE.md
Normal 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
20
docs/README.md
Normal 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.
|
||||||
Reference in New Issue
Block a user