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>
This commit is contained in:
2026-06-11 22:35:03 +02:00
parent e5b523e907
commit 82c3e1d605
7 changed files with 383 additions and 80 deletions

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.