Owning the store left a fresh install with no lists and no way to make one, so no way to save a task. The seam gains updateList/deleteList beside createLocalList on both paths — the External one addresses the row as its own account's sync adapter, the only caller the provider lets write tasklists. ListEditorSheet is the family's full-screen sheet: name field, a 12-colour palette, and a destructive row behind a confirm when editing. Entry points are a "New list" row under the home Lists section, an empty state with a create button, and the home FAB switching to "New list" while there are none. Deleting takes the list's tasks with it and is offered only for device-only lists. Four defects a review of the branch turned up: - completing one occurrence closed the whole series — setCompleted wrote the master, the row TaskDao.tasks filters on. setCompletedInstance forks a RECURRENCE-ID override the way updateInstance does; phase 2 always specified this, only the edit half had it - 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. TaskReminder carries the anchor now - registerObserver bound a live flow to whichever store was active at subscription, so a Settings store switch left every screen listening to the store it had stopped reading
289 lines
18 KiB
Markdown
289 lines
18 KiB
Markdown
# 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. Remaining work is
|
||
hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for
|
||
what has landed** (a storage-mode picker, an export screen), 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. Still ⬜ a **language** entry (deferred until there
|
||
are translations to switch to).
|
||
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
||
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
||
- ⬜ 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** — a storage-mode picker in Settings and
|
||
an export screen. The backend is done and unused until these exist.
|
||
- ⬜ 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`. **Breaking:** the
|
||
`de.jeanlucmakiola.agendula.tasks` authority and both custom permissions no
|
||
longer exist — anyone who pointed DAVx5 at that authority loses it, and the
|
||
release notes have to say so.
|
||
- ✅ 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.
|
||
- ⬜ **Run the instrumented suite on a device.** Six classes — the Room seam, the
|
||
DAOs, the import, the migration harness, the restore path and the performance
|
||
check — all compile and none has ever executed. Everything load-bearing about
|
||
this migration is verified only by tests that have not run.
|
||
- ⬜ Verify on a device: a fresh install on the Room store, and an upgrade from a
|
||
v0.3.2 APK with seeded data landing every task, list and reminder.
|
||
- ⬜ Per-locale release notes for the dropped authority and permissions.
|
||
|
||
### ✅ 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** — `autoMode()` picks a sane default
|
||
today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it
|
||
assumes is not built yet.
|
||
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.
|