# 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 ``, 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.