Files
agendula/docs/ROADMAP.md
T
makiolaj ee29c9bca1 test(store): assert the whole reminder, not a bare minute count
First on-device run of the instrumented suite against the branch tip: 66
tests, one failure, and it was the test that was wrong.

`alarmsRoundTripAndReplaceRatherThanAccumulate` asserted
`source.alarms()[id] == 30`. The seam stopped returning a bare `Int` in
fcee1d1, when collapsing an alarm to a minute count turned out to be what
fired an imported START-referenced reminder off DUE — `alarms()` has returned
`Map<Long, TaskReminder>` ever since. The production value was correct
(`TaskReminder(minutesBefore=30, fromStart=false)`); only the expectation was
left behind.

It compiled the whole time because Truth's `isEqualTo` takes `Any?`, so an
`Int` compared against a `TaskReminder?` is a perfectly legal call that can
only ever be false. Nothing short of running it would have found this, which
is the argument for the ROADMAP item that asked for the run.

Now asserts the whole value, so the reference is part of the contract rather
than something the test is free to ignore.

66/66 green after the fix.
2026-09-22 08:53:16 +02:00

358 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agendula — roadmap
Where the project is and where it's going. This is the **status** view; the
design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
pieces fit together is in [`ARCHITECTURE.md`](ARCHITECTURE.md).
Status legend: ✅ done · 🚧 in progress · ⬜ not started
---
## Current state (one line)
Agendula now **carries its own task store, and it is one we wrote**: a Room
database designed against `VTODO`, with recurrence expanded at read time. The
vendored dmfs provider and the `:provider` module that held it are deleted, a
v0.3.x install's tasks are imported on first launch, and an external provider
(OpenTasks / tasks.org) is a user choice rather than a requirement. Export to
iCalendar has landed, and the Material 3 Expressive UI is built through **M5**:
lists → task list (swipe gestures, inline add, smart-list section headers) →
detail / edit with full CRUD, date-time pickers, priority, percent-complete,
conflict-safe saves, per-task reminders, and subtask create + reparent — plus a
one-time reminder onboarding step and a Settings screen. The frontend surfaces
for the new store have landed too: a Settings **Storage** section with the
store picker and an **export screen**. Remaining work is verifying all of it on
a device, then M6 (Glance widget, translations, F-Droid release) and the sync
adapter.
---
## Milestones
### ✅ M0 — Skeleton
Project scaffolding copied from Calendula (Gradle, version catalog, Hilt,
Material 3 Expressive theme, Gitea CI/release workflows, F-Droid metadata),
reseeded to a warm-mauve fallback palette. Builds, shows a themed placeholder.
### ✅ M1 — Read path + logic (the "backoffice")
The complete non-visual stack:
- Vendored `TaskContract` subset, `ProviderResolver` (runtime authority
detection), `ColumnReader`, mappers, `AndroidTasksDataSource` (Instances
query + `ContentObserver`), and `TasksRepository` exposing live Flows of
lists / tasks / detail.
- Domain models, smart-list filtering (Today / Upcoming / Overdue / No-date /
All / Completed), sorting, form validation, subtasks via `parentId`.
- Self-scheduled due-reminder engine (AlarmManager + boot / provider-change
re-sync), notifications, DataStore prefs.
- Render-only ViewModels + UiState for **every** screen, so the UI is build-only
from here.
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and
day windows.
### ✅ M2 — Screens: lists, task list, complete & CRUD
The real Material 3 Expressive UI, built screen by screen against the M1
ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- ✅ Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
list → detail / edit, each binding its M1 ViewModel from route args).
- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd` → `quickAdd`),
smart-list section headers (`TaskSections`).
- ✅ Detail + edit screens — detail shows title / description / subtasks with
edit+delete; edit has the full CRUD form (title, description, save) wired to a
list picker.
### ✅ M3 — Detail / edit polish
- ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
- ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
list and detail screens, and the edit form's M3 segmented selector tints its
active segment in the chosen level's hue.
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0–100, status left to the
complete toggle).
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
value captured when the form loaded and throws `TaskConflictException`; the
editor offers overwrite-or-cancel instead of clobbering an external change.
### ✅ M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
- ✅ Create subtask (`AddSubtaskField` → `TaskDetailViewModel.addSubtask` sets
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
children too), and the subtask's detail shows a tappable "Part of …" parent
card so it never reads as a stray standalone task.
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
button (trailing chevron, separate from the count chip). Each section flattens
into one grouped run (parents + their expanded children) and corners are chosen
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
parent opens its bottom to its children, and the child group rounds off on its
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (on by default; opt out in Settings → Tasks since M5).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
groups active candidates by due-date section (Overdue / Today / Upcoming / No
date) and files a task under any top-level task in its list (or "None" to
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
### ✅ M5 — Reminders onboarding & polish
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
`DueReminderReceiver`, `TaskNotifier`).
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
`reminderMinutesBeforeDue`).
- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
provider/permission grant; it requests the runtime permission (API 33+) and
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
switch that gates the entire engine (`ReminderScheduler` clears all alarms when
off; `DueReminderReceiver` suppresses any in-flight fire).
- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
a device with a live provider).
### 🚧 M6 — Settings, widget, i18n, release
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
reached by the gear on the lists overview). Covers theme, dynamic colour, due
reminders (toggle + default offset + exact-alarm status), default list, and the
add-a-subtask-row opt-out — plus the **App language** picker, which landed with
the 0.4.0 translations.
- ⬜ Glance task-list widget — still just an idea, and now without a foothold:
the `glance-appwidget` / `glance-material3` dependencies were declared but never
used by a single line, so they were shipping dead weight in the APK and have
been removed. Re-add them with the implementation, not before.
- ✅ Translations — German and Brazilian Portuguese, via Weblate, shipped in
0.4.0 (`res/values-de`, `res/values-pt-rBR`, `res/xml/locales_config.xml`).
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ✅ Posture B — our own task store
Agendula stopped depending on a provider app being installed. Direction and
reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined**
what Posture B means (our own store, coexisting with everything — *not* squatting
`org.dmfs.tasks`, which is a dead end).
- ✅ `fix/provider-interaction-review` merged (step 1).
- ✅ Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored
in-tree as `:provider`, under `de.jeanlucmakiola.agendula.tasks` and our own
permission namespace. Shipped in v0.3.x and **since superseded**: see "our own
store" below.
- ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION`
can no longer fire in our own mode, and an upgrading Posture A user stays on the
provider that holds their data (`ProviderResolver.autoMode`).
- ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives
only in our app's private storage. One `.ics` per list, to a folder or a zip,
via SAF. Backend only.
- ✅ **Frontend surfaces for the above** — Settings gained a **Storage** section
holding both: a full-screen store picker (Own / an installed external provider,
which is dimmed when none is present) that asks for the provider's runtime
permission *before* committing the switch, and an export screen with a per-list
tick and the two SAF destinations, a folder or a single zip. Two consequences
of the mode becoming switchable at runtime came with it: reminders are re-armed
against the new store on every switch (`AgendulaApp` listens on
`ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit
resynced them), and the permission gate offers a way back to our own store —
otherwise a user whose provider app went away is held on a gate with Settings
behind it.
- ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users.
Note it now means "sync into an app that has no provider", so the ask has
changed shape.
- ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**,
not started: mapper → auth → engine → hardening, ~8–9 weeks, minus the 2.5–4
weeks owning the store deletes from it (`OWN-STORE.md`, "Effects on the sync
plan"). The account model is settled (`AccountManager`) and `ical4android` is
closed out (superseded by `synctools`, GPLv3, so we write the mapper
in-house); what's still open is dav4jvm's JitPack-only distribution, conflict
policy, and whether External mode survives the milestone.
### ✅ Our own store — Room, and the provider deleted
The vendored provider was kept because it appeared to hand us the sync
bookkeeping for free; the phase-1 sync audit measured that bookkeeping and found
most of it broken, absent or unusable. Reasoning in
[`STORAGE-DECISION.md`](STORAGE-DECISION.md), architecture and six-phase plan in
[`OWN-STORE.md`](OWN-STORE.md).
- ✅ Phase 0 — occurrences addressed by `(taskId, occurrenceStart)`. `Task.id`
(the materialised instance row id) dropped for `occurrenceStart`, lazy-list
keys moved to `Task.occurrenceKey`, `updateInstance` re-signed, `domain/`
stopped importing `TasksContract`. Done while the provider was still the store,
so the External path exercised the new seam first.
- ✅ Phase 1 — the schema: `task_lists`, `tasks`, `task_alarms`, `accounts`, a
DAO per table, the v1 schema JSON committed for migration testing. Masters and
`RECURRENCE-ID` overrides share the `tasks` table, so the unique index is
`(list_id, uid, recurrence_id)`.
- ✅ Phase 2 — `RecurrenceExpander` over `lib-recur` 0.12.2: a series expanded in
memory at read time, bounded by a window (1 year back, 2 forward) and a hard
per-series ceiling. No materialised instances table, so none of its staleness
bugs. 38 tests against RFC 5545 directly, since the provider only ever
materialised one occurrence to compare against.
- ✅ Phase 3 — `RoomTasksDataSource` implements all 14 seam methods, picked per
call by `ModeRoutingTasksDataSource`. Editing one occurrence writes a
`RECURRENCE-ID` override sharing the master's UID (RFC 5545 model (a)), where
the provider forked a new task with a new UID (model (d)). Completion rules are
stated directly rather than worked around, so a task can no longer strand
itself "done at 75%".
- ✅ Phase 4 — `OneShotImport` moves a v0.3.x install's `databases/tasks.db` into
Room on first launch, archiving the source as `tasks.db.imported`; `OWN` is the
default; `StartupGate` holds the first store read until the mode has landed and
the import has run; backup rules take the database with its WAL sidecars and
the app checkpoints on `ON_STOP`.
- ✅ Phase 5 — `:provider` deleted: 84 Java files, 14,555 lines, its `<provider>`,
its two custom permissions and its three dmfs runtime dependencies.
`StorageMode.LOCAL` is gone (a stored `LOCAL` reads as `OWN`); `ProviderResolver`
narrows to discovering external providers; `ProviderChangeReceiver` filters on
the two external authorities only. `provider/PROVENANCE.md` is replaced by a
postscript in `STORAGE-DECISION.md`. **Not a breaking change for anyone, as it
turns out:** the `de.jeanlucmakiola.agendula.tasks` authority and its two custom
permissions were added and deleted inside this same unreleased cycle
(`git tag --contains` on the commit that added `:provider` comes back empty), so
no published version ever carried them and nobody could have pointed DAVx5 at
one. The release notes must *not* warn about losing something that never
shipped.
- ✅ Phase 6 — harden: `MigrationTestHelper` wired against the committed v1
schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test
covering the WAL case in both directions, and a performance check at 5,000
tasks with 20 recurring series.
- ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is
what `OWN` returns, so no due reminder armed in the default mode. It now gates
on `ProviderResolver.canReadStore()`, with tests.
- ✅ Fallout, second pass — four defects a review of the branch turned up:
**completing one occurrence closed the whole series** (`setCompleted` wrote the
master, which is the row `TaskDao.tasks` filters on, so every occurrence left
every list); the **expansion ceiling was spent on the past**, so a sub-daily
series stopped expanding months before today and never reached Today or
Upcoming; an imported **`START`-referenced reminder fired off `DUE`**, because
the seam collapsed alarms to a bare minute count; and `registerObserver` bound
a live flow to whichever store was active at subscription, so a Settings
store switch would have left every screen listening to the store it had
stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override
the way `updateInstance` does — phase 2 always specified this, only the edit
half had it.
- ✅ **Ran the instrumented suite on a device** — 52 tests, 0 failures, Pixel 10
/ API 36, 13 Aug 2026; all six classes (the Room seam, the DAOs, the import, the
migration harness, the restore path, the performance check). Mind the ARM64
`aapt` trap: the task can exit non-zero on a fully green run, so read
`app/build/outputs/androidTest-results/connected/debug/*.xml` before believing
the exit code.
- ✅ **Re-ran it against the branch tip** — 66 tests, 0 failures, Pixel 10 /
API 37, 22 Sep 2026, now including `ExternalImportTest`. Worth having done:
the first run failed one test.
`RoomTasksDataSourceTest.alarmsRoundTripAndReplaceRatherThanAccumulate`
asserted `alarms()[id] == 30`, from before the seam returned a `TaskReminder`
rather than a bare minute count — the production value was right
(`TaskReminder(minutesBefore=30, fromStart=false)`) and the assertion was
stale. It compiled because Truth's `isEqualTo` takes `Any?`, so nothing but
executing it could have caught it. Exactly the defect class this item
existed to find.
- ⬜ Verify on a device — and note that the upgrade path this phase was designed
around is **not** the one real users are on. `OneShotImport` reads a bundled
dmfs provider's `databases/tasks.db`, and no release ever bundled one, so every
existing install's tasks sit in OpenTasks or tasks.org instead. What has to be
checked is therefore:
1. a fresh install landing on the Room store with nothing installed;
2. an upgrade from the 0.4.0 APK **with OpenTasks installed and permitted** —
`autoMode()` must keep them on External and show their tasks unchanged;
3. Settings → Storage → *Copy tasks from …* moving those tasks into the Room
store, then the switch to *On this device* showing them, reminders included;
4. `OneShotImport` itself, which on a real device is only reachable by
side-loading a dev build of this branch first.
- ⬜ Release notes for 1.0.0. Nothing to say about the dropped authority or
permissions (see Phase 5); what needs saying is the own store, the copy path out
of an external provider, iCalendar export and list management.
### ✅ A way onto the new store for the people already using Agendula
The migration this branch was planned around turned out to serve nobody: the
vendored provider it reads from never shipped, so no install has the file
`OneShotImport` looks for. Everyone on 0.4.0 keeps their tasks in OpenTasks or
tasks.org, which `autoMode()` correctly keeps them on — and until now the only
route to the new store was to retype everything.
- ✅ `ExternalImport` (`data/tasks/transfer/`) copies the external provider's
lists, tasks and alarms into Room in one transaction with verified counts, the
same discipline as the legacy import. Task `uid`s survive, so these rows can be
attached to a CalDAV collection once sync lands instead of duplicating.
- ✅ Offered as **Settings → Storage → Copy tasks from …**, behind a confirm that
names the real number of tasks, and only while a provider is installed,
permitted, and no copy has succeeded yet — a second run would leave two of
everything, since what it writes is indistinguishable from hand-typed tasks
afterwards.
- ✅ One-directional by design. Writing a *list* into a third-party provider
means impersonating its sync adapter, and the external store is already the one
that can sync. What does not come across is documented on the class:
per-occurrence overrides, `EXDATE`, `CLASS`, `DURATION`, the per-task timezone,
and a task's exact `PRIORITY` digit.
- ✅ Switching stores at all now asks first. Neither store hands its rows to the
other, so the app looks emptied to anyone who expected a move.
- ✅ A failed `OneShotImport` is no longer silent. It used to return an
`ImportResult.Failed` that every caller dropped, leaving an upgrading user an
empty app and no explanation; it is now recorded, logged, and surfaced in
Settings → Storage as a retry — which is also what finally makes
`reimportFromArchive()` reachable from the app.
### ✅ Managing lists in the app
Owning the store made this mandatory: there is no longer a provider app to
create a list in, so a fresh install had no lists, no way to make one, and
therefore no way to save a task. The seam gained `updateList` / `deleteList`
beside the existing `createLocalList`, implemented on both the Room and the
External path (which addresses the row as its own account's sync adapter, the
only caller the provider lets write `tasklists`).
- ✅ `ListEditorSheet` — the family's full-screen sheet with a name field, the
12-colour palette and, when editing, a destructive row behind a confirm.
- ✅ Entry points: a "New list" row under the home Lists section, a real empty
state with a create button, and the home FAB switching to "New list" while
there are none. Editing is the pencil in a list's own top bar.
- ✅ Deleting a list deletes its tasks — `tasks.list_id` cascades — and is
offered only for device-only lists; an account's collection is its server's.
---
## Open decisions / to verify
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) Note this is now downstream of
[`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync
ourselves, the question disappears with it.
4. ~~**Posture B authority choice**~~ moot: Agendula publishes no provider and
holds no authority at all. The question was live while the store was a
vendored provider, and squatting `org.dmfs.tasks` was a dead end even then —
two apps cannot declare the same authority or permission name, so anyone with
OpenTasks installed could not have installed Agendula at all.
5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~
resolved: our own store expands a series at read time and writes an edit to
one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in
External mode the edit still goes through the instances URI.
6. ~~**Resolver ordering / mode-selection UX**~~ resolved: `autoMode()` picks the
default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage
→ Task store is the override it always assumed.
7. ~~**Sync protocol coverage**, account model, conflict resolution — the next
design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens
live on that document's list.
---
## How to contribute / verify
- Build: `./gradlew :app:assembleDebug`
- Unit tests: `./gradlew :app:testDebugUnitTest`
- Instrumented tests: `./gradlew :app:connectedDebugAndroidTest` — the Room
schema, `RoomTasksDataSource` and `OneShotImport` (the last against
`app/src/androidTest/assets/tasks-v23.db`, regenerated by
`scripts/make_import_fixture.py`).
- Any device or emulator will do for the default path: the store is ours and
needs nothing installed. Debug builds seed an "Agendula Demo" list via
`DemoSeeder` unless it already exists. To exercise **External** mode, use a
device with **OpenTasks**
or **tasks.org** installed, and ideally DAVx5 syncing a CalDAV task list.