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>
148 lines
6.9 KiB
Markdown
148 lines
6.9 KiB
Markdown
# 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.
|