The vendored dmfs provider was kept on the grounds that it hands us the sync bookkeeping for free. The phase-1 sync audit measured that bookkeeping and found most of it broken, absent, or unusable: _DIRTY not set on delete, no home for a per-collection sync token, read-only collections inexpressible, ACCOUNT_TYPE write-once so enabling sync is a full migration, and cleanUpLists able to delete a user's lists after a backup restore. Sixteen findings are provider-imposed rather than platform- or protocol-imposed. Costing the alternative showed the swap is far smaller than assumed. TasksDataSource is already a 14-method, domain-shaped interface; exactly one file above the data layer references TasksContract. The work is a second implementation behind an interface built for it, not a rewrite. Against ~5 weeks to build, owning the store removes 2.5-4 weeks from the sync plan, and 8,200 of the vendored 14,555 lines are things we would never write - 23 migrations from a 2013 schema, 798 lines of full-text search the app has zero call sites for, and 1,581 lines of a type-safe layer over ContentValues that Room deletes. External mode (OpenTasks, tasks.org) is unaffected and keeps every file that describes somebody else's schema. STORAGE-DECISION.md is the reasoning; OWN-STORE.md is the architecture and the six-phase plan. :provider stays in-tree until phase 5 so recurrence parity can be tested against it before it goes. Also corrected here: the provider's JVM test count (51 -> 56, measured from the test-results XML) and a fourth site of the debunked "switching sync on is never a migration" claim, in StorageMode.kt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
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, and how the
pieces fit together is in ARCHITECTURE.md.
Status legend: ✅ done · 🚧 in progress · ⬜ not started
Current state (one line)
Agendula now carries its own task store: the :provider module ships the
vendored dmfs provider under our authority, so the app is complete and local-first
with nothing else installed, and an external provider (OpenTasks / tasks.org) is a
user choice rather than a requirement. The non-visual stack over TaskContract is
done and unit-tested, 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 the frontend surfaces for what just 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
TaskContractsubset,ProviderResolver(runtime authority detection),ColumnReader, mappers,AndroidTasksDataSource(Instances query +ContentObserver), andTasksRepositoryexposing 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+Destroute 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 (
DateTimePickerFlowinTaskEditScreen). - ✅ Priority — coloured by level: green / amber / red pastels (
priorityFill; M3 has no priority role, onlyerror). A sharedui/common/PriorityChipon 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 (
TaskSectionsheaders 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 —
updateTaskre-checkslast_modifiedagainst the value captured when the form loaded and throwsTaskConflictException; 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.addSubtasksetsparentId); subtasks render as a grouped section in the detail screen. Tapping a subtask opens its own detail (a newTaskDetailentry, 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 (surfaceContainervs 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.parentcarries 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→TaskFormreminderMinutesBeforeDue). - ✅
POST_NOTIFICATIONSonboarding flow — a one-timeReminderOnboardingScreen(Calendula's shell + copy adapted for tasks) gated inRootScreenafter 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_ALARMis revocable), deep-linking to the system grant screen; on 13+ the app holdsUSE_EXACT_ALARM(always granted). - ✅ Default reminder-offset settings UI — exposed as "When to remind" in
Settings → Reminders (
reminderLeadMinutes), behind a masterremindersEnabledswitch that gates the entire engine (ReminderSchedulerclears all alarms when off;DueReminderReceiversuppresses 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 (
SettingsScreenin 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); novalues-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; note it redefined
what Posture B means (our own authority, coexisting with everything — not
squatting org.dmfs.tasks, which is a dead end).
- ✅
fix/provider-interaction-reviewmerged (step 1). - ✅
:provider— the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree underde.jeanlucmakiola.agendula.tasksand our own permission namespace.GET_ACCOUNTSdropped, with the account-cleanup path reworked so it can only prune account types we authenticate ourselves — the deletion is unsafe without that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17.provider/PROVENANCE.mdrecords every deviation, each also markedAGENDULA CHANGEat the site. Upstream's 56 JVM tests pass. - ✅ Storage modes + the permission-gate bypass —
ProviderStatus.NEEDS_PERMISSIONcan no longer fire in Local 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 Local-mode data lives
only in our app's private storage. One
.icsper 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.
- ⬜ Sync adapter (step 5) — the 1.x arc. Designed in
SYNC.md, not started: mapper → auth → engine → hardening, ~8–9 weeks. The account model is settled (AccountManager) andical4androidis closed out (superseded bysynctools, 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. - ⬜ Verify on a device: the local path with no account, and the vendored
provider's timezone-change behaviour (change 3 in
PROVENANCE.md).
Open decisions / to verify
These carry over from PLAN.md §9; resolved ones are struck through.
Name —confirmed (appIdAgendulade.jeanlucmakiola.agendula).tasks.org provider authorityverified on device:org.tasks.opentasks+org.tasks.permission.*.- jtx Board — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) Note this is now downstream of
SYNC.mdopen question 3: if External mode is retired once we sync ourselves, the question disappears with it. Posture B authority choiceresolved: our ownde.jeanlucmakiola.agendula.tasks. Squattingorg.dmfs.tasksis a dead end, not merely a trade-off — two apps cannot declare the same authority or permission name, so anyone with OpenTasks installed could not have installed Agendula at all.Recurring tasks — recurrence-aware editing out of scope for v1stale:fix/provider-interaction-reviewroutes edits to a recurring task through the instances URI, so the provider forks an override instead of re-anchoring the series.- Resolver ordering / mode-selection UX —
autoMode()picks a sane default today (seeARCHITECTURE.md§4.1); the Settings override it assumes is not built yet. Sync protocol coverage, account model, conflict resolution — the next design discussion.Taken up inSYNC.md; the remaining opens live on that document's list.
How to contribute / verify
- Build:
./gradlew :app:assembleDebug - Unit tests:
./gradlew :app:testDebugUnitTest - Run on a device/emulator that has OpenTasks or tasks.org installed (and
ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real
data. Debug builds use
DemoSeederfor sample data when no provider data is present.