The store picker and the export screen were the two frontend surfaces the own-store work left unbuilt, so both backends shipped unreachable. Settings gains a Storage section holding them: a full-screen picker over Own / an installed external provider (dimmed when none is present, named after the provider's own app), and an export screen with a per-list tick and the two SAF destinations, a folder or a single zip. The picker asks for the provider's runtime permission before writing the mode, so a denial leaves the readable store in place instead of dropping the user on the gate; a refusal is reported with a route to app settings. Making the mode switchable at runtime had two consequences: - reminders are armed off whichever store was active when they were scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is now serialised — it is a read-modify-write over ScheduledReminderStore, and overlapping runs each wrote their own set as the whole truth - the permission gate is the only screen an External user can reach once their provider app stops answering, so it offers the way back to our own store ExportWriter no longer deletes a previous export before recreating it (a failure in between lost both), lists the target directory once instead of per document, and carries a typed ExportFailure so the screen can report in the user's language rather than an exception message.
18 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, 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
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 store, coexisting with everything — not squatting
org.dmfs.tasks, which is a dead end).
- ✅
fix/provider-interaction-reviewmerged (step 1). - ✅ Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored
in-tree as
:provider, underde.jeanlucmakiola.agendula.tasksand 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_PERMISSIONcan 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
.icsper 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 (
AgendulaApplistens onProviderResolver.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, 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) 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.
✅ 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, architecture and six-phase plan in
OWN-STORE.md.
- ✅ Phase 0 — occurrences addressed by
(taskId, occurrenceStart).Task.id(the materialised instance row id) dropped foroccurrenceStart, lazy-list keys moved toTask.occurrenceKey,updateInstancere-signed,domain/stopped importingTasksContract. 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 andRECURRENCE-IDoverrides share thetaskstable, so the unique index is(list_id, uid, recurrence_id). - ✅ Phase 2 —
RecurrenceExpanderoverlib-recur0.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 —
RoomTasksDataSourceimplements all 14 seam methods, picked per call byModeRoutingTasksDataSource. Editing one occurrence writes aRECURRENCE-IDoverride 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 —
OneShotImportmoves a v0.3.x install'sdatabases/tasks.dbinto Room on first launch, archiving the source astasks.db.imported;OWNis the default;StartupGateholds 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 onON_STOP. - ✅ Phase 5 —
:providerdeleted: 84 Java files, 14,555 lines, its<provider>, its two custom permissions and its three dmfs runtime dependencies.StorageMode.LOCALis gone (a storedLOCALreads asOWN);ProviderResolvernarrows to discovering external providers;ProviderChangeReceiverfilters on the two external authorities only.provider/PROVENANCE.mdis replaced by a postscript inSTORAGE-DECISION.md. Breaking: thede.jeanlucmakiola.agendula.tasksauthority 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:
MigrationTestHelperwired 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 onresolve() != null, which is whatOWNreturns, so no due reminder armed in the default mode. It now gates onProviderResolver.canReadStore(), with tests. - ✅ Fallout, second pass — four defects a review of the branch turned up:
completing one occurrence closed the whole series (
setCompletedwrote the master, which is the rowTaskDao.tasksfilters 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 importedSTART-referenced reminder fired offDUE, because the seam collapsed alarms to a bare minute count; andregisterObserverbound 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.setCompletedInstancenow forks aRECURRENCE-IDoverride the wayupdateInstancedoes — 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_idcascades — 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 §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 choicemoot: Agendula publishes no provider and holds no authority at all. The question was live while the store was a vendored provider, and squattingorg.dmfs.taskswas 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.Recurring tasks — recurrence-aware editing out of scope for v1resolved: our own store expands a series at read time and writes an edit to one occurrence as aRECURRENCE-IDoverride sharing the master's UID; in External mode the edit still goes through the instances URI.Resolver ordering / mode-selection UXresolved:autoMode()picks the default (seeARCHITECTURE.md§4.1) and Settings → Storage → Task store is the override it always assumed.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 - Instrumented tests:
./gradlew :app:connectedDebugAndroidTest— the Room schema,RoomTasksDataSourceandOneShotImport(the last againstapp/src/androidTest/assets/tasks-v23.db, regenerated byscripts/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
DemoSeederunless it already exists. To exercise External mode, use a device with OpenTasks or tasks.org installed, and ideally DAVx5 syncing a CalDAV task list.