Their exporter writes the last day an all-day event occupies as DTEND, where RFC 5545 means the day after. Read literally every all-day event was a day short and single-day ones — birthdays, memorials, name days — came out zero length, which the provider expands into no instances at all, so they never appeared. IcsQuirks sniffs the producer and shifts the end; independently, an all-day event may no longer end at or before its start and an all-day DURATION is floored at P1D. Fossify also emits a bare ";BYDAY=" for weekly events with no weekday mask. EventRecurrence.parse throws on that, out of insert, and nothing caught it — one such row failed the entire file. sanitizeRrule now drops empty and malformed parts, and importEvents isolates each event, counting rejects into IcsImportSummary.failed instead of unwinding the batch. Rest of the dialect: VTODO imports as events (was dropped silently), CATEGORIES stands in for the X-WR-CALNAME they never write, bare EXDATE day codes resolve against a timed series' own time of day, and their positive VALARM trigger is read as "on the day" so all-day reminders fire at the hour the setting names rather than at UTC midnight. Colours come across too: X-FOSSIFY-EVENT-COLOR / COLOR / the category colour as fallback, plus the X-SMT-* legacy spellings, snapped in Oklab to the nearest key a palette account publishes since those reject a raw EVENT_COLOR. Closes #225
401 lines
23 KiB
Markdown
401 lines
23 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
|
|
(the crash reporter's web-issue path is the worked example).
|
|
|
|
## Layers
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
subgraph UI ["ui/ — Compose screens + ViewModels"]
|
|
Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"]
|
|
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/\nReminderScanner + 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/` (recurrence humanizer, FAB column,
|
|
drawer, transitions). Selection pickers are full-screen and come from
|
|
floret-kit (`FullScreenPicker` / `OptionPicker`); `AlertDialog` is reserved
|
|
for plain confirmations and the compact recurring-scope choosers.
|
|
`ui/settings/` is the exception to "one file per screen": one
|
|
`SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a sub-screen per
|
|
category, each in its own `*Settings.kt`.
|
|
- **`floret-kit/`** — the shared Material 3 Expressive kit for the Floret app
|
|
family, wired in as a git submodule *and* a Gradle composite build
|
|
(`includeBuild`), so it is compiled from source rather than resolved as a
|
|
dependency. Pickers, crash plumbing, and locale/time helpers live there;
|
|
changing them is a pull request against that repository plus a submodule bump.
|
|
|
|
## Navigation
|
|
|
|
There is no navigation library. `MainActivity` hosts `RootScreen`, which
|
|
gates on the first-launch wizard (`ui/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.
|
|
|
|
### First-launch wizard
|
|
|
|
`ui/onboarding/` is a plan, not a navigation graph: `onboardingPlan()` is a
|
|
pure function of the stored answers that returns the whole flow *plus* the
|
|
step it is on, and `RootScreen` renders that step. Two rules make it work:
|
|
|
|
- The plan keeps **completed** steps, so finishing one never renumbers the
|
|
counter under the user, and stepping *back* is just clearing the previous
|
|
step's answer — there is no separate back stack. The calendar grant is the
|
|
one step with nothing to return to, so back stops there.
|
|
- The optional steps (backup, view) only exist for an install still on its
|
|
first run — `armOnboardingWizard()` arms on any launch holding the calendar
|
|
permission but refuses if the reminder step was already answered, and the
|
|
closing screen clears the flag again, so revoking and re-granting the
|
|
permission later never re-onboards anyone.
|
|
|
|
Whether the backup step applies (nothing writable is synced anywhere) cannot
|
|
be known before the grant, so it is assumed until the calendar list can be
|
|
read: the flow may shrink after the grant, never sprout a step the counter
|
|
had not accounted for.
|
|
|
|
The flow closes on an "all set" screen, and the one-time calendar-visibility
|
|
notice (#75) is a step in front of it rather than a dialog over the app — it
|
|
is the only optional step an *already onboarded* install can be given, which
|
|
is why it alone is not gated on the wizard being armed.
|
|
|
|
## Recurring writes
|
|
|
|
The provider's invariants drive the design (learned the hard way, verified
|
|
on-device):
|
|
|
|
- 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.
|
|
|
|
### Drag to reschedule
|
|
|
|
Dropping an event on another slot (#68) is not a second write path: the drop
|
|
loads the event, prefills the **same** `toEditForm` the edit screen uses, shifts
|
|
it (`EventShift.kt`), and dispatches through the same three repository calls a
|
|
save does — so recurring writes, reminder reconciliation and attendee
|
|
preservation behave identically. The full form is carried through, never a
|
|
stripped one: `updateOccurrence` reconciles reminders *and* attendees onto the
|
|
new exception row, and a partial form would wipe them.
|
|
|
|
Two things the drag has to get right that the edit screen sidesteps:
|
|
|
|
- **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule
|
|
verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence
|
|
onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday
|
|
rule and the series does not move. `realignRecurrence` re-derives `BYDAY`, and
|
|
returns **null** for everything else — the drop then offers only *this event*,
|
|
whose exception row carries no rule at all. The same staleness is reachable
|
|
from the edit screen; wiring it there too is a separate change.
|
|
|
|
What the rule has to agree with is the **series anchor**, not the occurrence
|
|
being dragged, and `buildEventUpdateValues` moves that anchor by the same
|
|
*wall-clock* shift. So the realigner only touches weekday, which is uniform mod
|
|
7 and therefore survives that shift whatever time of day the anchor sits at.
|
|
Day-of-month is not uniform — `BYMONTHDAY=28` with a January anchor, occurrence
|
|
Feb 28 dragged to Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1`
|
|
rule, a DTSTART that is not an instance of its own rule and a phantom
|
|
occurrence on any client that trusts it.
|
|
|
|
Uniform mod 7 is not enough on its own: the anchor also has to cross the *same
|
|
number of midnights* as the occurrence did, or the rebuilt weekday is off by a
|
|
day. `RescheduleViewModel` requires that of every write wider than one
|
|
occurrence, in the two shapes a drag comes in:
|
|
|
|
- a drag that **changes the day** must be a whole number of days, so the two
|
|
move in step whatever the anchor's time of day;
|
|
- a **time-only** drag must leave the anchor on its own day. That is normally
|
|
free, since the anchor shares the occurrence's time of day — but a row with
|
|
no `EVENT_TIMEZONE` resolves anchor and occurrence in zones that can sit a
|
|
DST hour apart, and a near-midnight drag would then carry the anchor across a
|
|
midnight the occurrence never crossed.
|
|
|
|
Whatever falls outside — a rule the realigner won't rebuild, or a shift that
|
|
would move rule and anchor out of step — may only move the one occurrence.
|
|
|
|
The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence`
|
|
understands, so anything realignable is also a rule whose `UNTIL` the guard
|
|
below can actually read.
|
|
- **The eligibility gate is load-bearing.** Nothing below the UI refuses a
|
|
write, so `CalendarSource.allowsEventMove` is what keeps read-only and
|
|
contact-managed events from being dragged. It is deliberately *not*
|
|
`isEventTarget`: a managed event is editable (reminders, notes) yet must never
|
|
move, while a switched-off calendar renders nothing to grab anyway.
|
|
|
|
A drop that would push a series past its own `UNTIL` — the provider then
|
|
generates zero occurrences and the event vanishes from every view — is refused
|
|
rather than written. The check happens at **write** time, not at drop time,
|
|
because which date has to clear `UNTIL` depends on how far the write reaches: a
|
|
whole-series move carries the *anchor*, a split starts a new series at the moved
|
|
occurrence, and a single occurrence becomes an exception row that no `UNTIL`
|
|
constrains. Testing the occurrence in every case would refuse the perfectly
|
|
ordinary drag of a bounded series' last occurrence.
|
|
|
|
**One drop is written at a time.** Two drops of the same recurring event landing
|
|
inside one write window would each compute their shift from the same pre-move
|
|
occurrence, while the data layer applies both to the re-read anchor — so the
|
|
shifts would compound. `RescheduleViewModel.move` therefore refuses while a write
|
|
(or its scope dialog) is outstanding, and says so in its return value: the view
|
|
that took the drop releases the block it was holding on the target instead of
|
|
waiting out a settle that will never arrive.
|
|
|
|
Two known limitations of a whole-series move, both shared with the edit screen's
|
|
own *All events* time save rather than introduced here — a drag just makes them
|
|
one gesture away: the series' `EXDATE` stamps and its exception rows are **not**
|
|
re-anchored, so previously deleted occurrences can reappear and previously
|
|
modified ones stay behind while the rest of the series moves.
|
|
|
|
### Event time zones
|
|
|
|
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
|
"the device zone at save time"** — not "no zone". The data layer resolves it in
|
|
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
|
|
event behaves exactly as it did before the field existed.
|
|
|
|
- A non-null value **pins** the event: it keeps tracking that zone's offset
|
|
across DST no matter where the device is. `toEditForm` only pins when the
|
|
stored zone differs from the device's, so the optional Time-zone field stays
|
|
hidden on ordinary events and reveals itself (via `populatedFields`) on
|
|
foreign-zone ones.
|
|
- A pinned event is prefilled **in its own zone**, so the form shows the
|
|
wall-clock the event means rather than the device's rendering of it.
|
|
- A zone change counts as a **time change** even with the wall-clock untouched
|
|
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
|
|
includes it in `timesChanged` and rewrites `DTSTART`.
|
|
- **All-day events never carry a zone.** They're date-anchored — the UTC
|
|
midnights above are an anchor, not a location — so the field is withheld from
|
|
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
|
|
|
|
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
|
|
`AllDayReminderEncoding`'s offset (see its KDoc).
|
|
|
|
## 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.
|
|
|
|
A **dropped** event gets no conflict dialog, deliberately. Its blast radius is
|
|
already bounded by the same dirty check — only `ALL_DAY`, `EVENT_TIMEZONE`,
|
|
`DTSTART` and `DTEND`/`DURATION`/`RRULE` are written — so a concurrent remote
|
|
edit to the title, notes or guests survives untouched. What a drop *can* clobber
|
|
is a concurrent remote **time** change, and parking a one-gesture action behind a
|
|
modal would cost more than that case is worth; the undo on the confirmation chip
|
|
is the answer instead. Undo restores semantics, not the row's byte
|
|
shape (`DURATION` normalises to `P<n>S`/`P<n>D`, `EVENT_TIMEZONE` is stamped
|
|
concrete), and it is offered only where the inverse is one symmetric write — a
|
|
one-off event or a whole-series shift. *This event* leaves an exception row
|
|
behind and *this and following* splits the series; neither is undone by shifting
|
|
back, so both get a plain confirmation. Two further gaps, both narrow and
|
|
accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall
|
|
clock (the −Δ normalises back to where it started), and an undo after a
|
|
concurrent remote time change overwrites it, exactly as the forward move would.
|
|
|
|
## Reminder delivery
|
|
|
|
Calendula plans and fires its own reminders. It reads the offsets in
|
|
`Reminders` as data, works out when each occurrence's reminder is due, and holds
|
|
**one** exact alarm for the earliest one still ahead:
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
|
|
participant Sc as ReminderScanner
|
|
participant Src as ReminderInstanceSource
|
|
participant P as ReminderPlan (pure)
|
|
participant N as ReminderNotifier
|
|
participant A as ReminderAlarmScheduler
|
|
T->>Sc: scan()
|
|
Sc->>Src: occurrences(window) + reminderMinutes(ids)
|
|
Src-->>Sc: Instances ⋈ Reminders
|
|
Sc->>P: planReminders / scheduleReminders(watermark, now)
|
|
P-->>Sc: due + next alarm
|
|
Sc->>N: post(alert) — tag = reminder key
|
|
Sc->>A: scheduleScan(next)
|
|
```
|
|
|
|
**Why not the provider's broadcast.** It used to schedule the alarms, write the
|
|
`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
|
|
That chain holds on stock Android and demonstrably not everywhere: AOSP's own
|
|
unbundled calendar carries three separate workarounds for OEM providers that
|
|
retarget the broadcast or only write the alert row at alert time. A reacting app
|
|
cannot tell "nothing was due" from "the broadcast never came" (#75) — and the
|
|
reporter's silent events were in a calendar Calendula created itself, so
|
|
`VISIBLE` was never the cause there.
|
|
|
|
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
|
|
whose moment falls in `(lastScan, now]`, then moves the mark
|
|
(`ReminderStatePrefs`). Half-open, so a scan that runs twice cannot post twice,
|
|
while a scan that runs *late* still posts what the missed alarm owed — a reboot,
|
|
an app update or a doze window costs nothing. A first-ever scan claims the
|
|
present rather than the epoch, and a watermark left in the future by a clock
|
|
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
|
|
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
|
|
matter because both wipe pending alarms. Turning reminders off cancels the alarm
|
|
and granting the calendar permission arms none, so those transitions scan too —
|
|
without it, switching reminders back on would sit silent until the daily worker.
|
|
The window a scan reads is the 7-day lookahead plus the longest reminder offset
|
|
in the provider, capped at a year: that offset is whatever the largest row says,
|
|
including one imported from a stray `TRIGGER:-P100W`.
|
|
|
|
**All-day reminders fire at the hour the setting names.** The stored offset is
|
|
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
|
|
sampled against one date's UTC offset — so taking it at face value drifts by the
|
|
offset delta across a DST boundary, and rows from other apps carry no hour at
|
|
all. The offset is therefore read only for *which day* it means; the hour comes
|
|
from the global all-day reminder setting, recomposed against each occurrence's
|
|
own date. Which day that is comes from the local date the encoded instant falls
|
|
on — except for a plain multiple of 1440, read at face value because a foreign
|
|
row means literal days from UTC midnight. The two collide where the all-day hour
|
|
equals the zone's UTC offset (20:00 in New York), and there the instant landing
|
|
on the named hour decides it is ours; the display path decodes through the same
|
|
function, so the screen and the notification agree. Timed reminders need none of
|
|
this: `begin` is an absolute instant.
|
|
|
|
**One visibility model.** The scan only plans occurrences of calendars with
|
|
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
|
|
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
|
|
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
|
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
|
runs one way only: a calendar the user switched off in Calendula is switched off
|
|
in the provider, never the reverse — un-hiding one would reach into every other
|
|
calendar app on the device — and a one-time notice explains the calendars that
|
|
were already off. `CalendarPrefs.pendingDisabledCalendarIds` holds the switch-offs
|
|
the app has not been allowed to write yet (read-only permission grant, or a
|
|
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
|
|
and until it does, the repository and `ReminderNotifier.post` honour it. That
|
|
gate also covers a snooze re-shown from our own alarm after its calendar was
|
|
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
|
|
separate in-app declutter that never touches reminders.
|
|
|
|
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
|
Keeping both would double-post wherever the provider works, and Etar's way out —
|
|
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
|
be copied, because our failure mode includes a broadcast that arrives with no
|
|
alert row behind it.
|
|
|
|
## Importing foreign `.ics`
|
|
|
|
`IcsParser` is deliberately liberal, because the files it is handed were written
|
|
by other people's calendars. Two lessons from migrating users off Fossify
|
|
Calendar (#225) shape the read path.
|
|
|
|
**All-day `DTEND` is not always exclusive.** RFC 5545 defines it as the first
|
|
day *after* the event; the Simple Calendar / Fossify family writes the last day
|
|
the event occupies. Read literally, every one of their all-day events is a day
|
|
short and single-day ones — birthdays, anniversaries, name days, i.e. most of
|
|
what anyone migrates — collapse to zero length, at which point the provider
|
|
expands the series into no instances and the event simply is not there.
|
|
`IcsQuirks` sniffs the producer (`PRODID`, or the `X-FOSSIFY-*` / `X-SMT-*`
|
|
extensions for files that passed through a rewriter) and shifts the end by a
|
|
day. Independently of any dialect, an all-day event is never allowed to end at
|
|
or before it starts, and `buildImportedEventValues` floors an all-day `DURATION`
|
|
at `P1D` — belt and braces, because the failure is invisible rather than loud.
|
|
|
|
**One bad event must not cost the file.** The provider validates `RRULE` through
|
|
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
|
|
read. Fossify emits a bare `;BYDAY=` for any weekly event with no weekday mask,
|
|
so a single such row used to abort the whole import with nothing but "failed" on
|
|
screen. `sanitizeRrule` drops empty and malformed parts before the write, and
|
|
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
|
|
`IcsImportSummary.failed` rather than unwinding the batch.
|
|
|
|
The rest of the dialect handling is smaller: `VTODO` components import as events
|
|
(Calendula models no tasks; dropping them silently lost half of some exports),
|
|
`CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes, bare `EXDATE`
|
|
day codes on a timed series are resolved against the series' own time of day, and
|
|
a positive `VALARM` trigger on an all-day event is that family's encoding of "on
|
|
the day at 09:00" — read as zero days before, so it fires at the hour the user's
|
|
own setting names.
|
|
|
|
Colour arrives as a raw ARGB from an app with no idea which account it is landing
|
|
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
|
|
`buildImportedEventValues` snaps the imported colour to the nearest published key
|
|
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
|
|
palette. The single-event review form deliberately keeps no colour at all: its
|
|
first question is which calendar to use, and answering it clears the colour
|
|
anyway.
|
|
|
|
## 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
|
|
(Forgejo Actions on Codeberg) runs `lint test assembleDebug` once per pull
|
|
request; merging a
|
|
bumped `versionName` to `main` builds, signs, and publishes to the self-hosted
|
|
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.
|