STORAGE-AND-SYNC.md asked for a follow-up pass on ARCHITECTURE.md §7 and the
ProviderResolver KDoc, which still defined Posture B as "bundle OpenTasks and
find org.dmfs.tasks first" — the plan that was withdrawn as a dead end. That pass,
plus the status the doc left open.
ARCHITECTURE.md now describes the app as built: two modules, the storage-mode
table with the permission each needs, the autoMode rule and why it keys on
holding an external provider's permission, the two-not-three mode vocabulary, and
a manifest section that says what :provider contributes and what is deliberately
absent (GET_ACCOUNTS, INTERNET). §7 records squatting the dmfs authority as a
dead end rather than a road not yet taken, so it doesn't get re-proposed.
ROADMAP.md turns "Posture B, later" into what actually landed and lists what
didn't: the frontend surfaces, the DAVx5 issue, the sync adapter, and device
verification. Two open decisions resolved and struck through — the authority
choice, and recurrence-aware editing, which fix/provider-interaction-review made
stale.
STORAGE-AND-SYNC.md gets per-step status. Open question 3 ("does it work with no
account?") is answered, with the caveat that the test proving it is Robolectric
and skips on ARM64 — answered by construction, not yet on a device.
PLAN.md gets a banner. It's the original design document and still holds the
reasoning behind the layering, but two of its premises are overturned and it
should not be read as current.
README.md was telling users they need a tasks provider installed. They don't, and
that's the headline feature: a table of where tasks can live, that our provider
coexists with OpenTasks rather than replacing it, and that everything exports as
standard .ics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
342 lines
17 KiB
Markdown
342 lines
17 KiB
Markdown
# Agendula — implementation plan
|
||
|
||
> ⚠️ **Historical document.** This is the original design plan, kept for the
|
||
> reasoning behind decisions that are still in force — the layering, the data
|
||
> model, the reminder engine, what transfers from Calendula. It is **not** a
|
||
> description of the app as it stands.
|
||
>
|
||
> Two things here have since been overturned, both by
|
||
> [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md), which supersedes this document
|
||
> wherever they disagree:
|
||
>
|
||
> 1. **"No own storage."** Agendula now ships its own bundled task provider, and
|
||
> depending on an external provider app is a user choice rather than a
|
||
> requirement.
|
||
> 2. **"Posture B = bundle OpenTasks under `org.dmfs.tasks`."** That is a dead
|
||
> end, not a later step — two apps cannot declare the same authority or
|
||
> permission name. Posture B shipped under *our own* authority instead.
|
||
>
|
||
> For the current picture see [`ARCHITECTURE.md`](ARCHITECTURE.md); for status,
|
||
> [`ROADMAP.md`](ROADMAP.md).
|
||
|
||
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
|
||
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
|
||
> DecSync over CalDAV), with no own sync stack.
|
||
>
|
||
> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`;
|
||
> Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The
|
||
> name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula`
|
||
> ending — and a Calendula flower head is itself a cluster of small *florets*,
|
||
> so the two apps are florets of one bloom.
|
||
|
||
Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula**
|
||
(renamed from the working title *Floret*, which was promoted to the shared
|
||
family / design-language name).
|
||
|
||
---
|
||
|
||
## 0. The thesis this app embodies
|
||
|
||
A nice M3-Expressive front end over open backends, no reinvented storage or
|
||
sync. Calendula proved the pattern against the OS calendar provider. Agendula
|
||
applies it to tasks. The crucial difference: **there is no OS tasks provider**,
|
||
so we depend on a tasks *provider app* being present — exactly as Calendula
|
||
depends on a sync app like DAVx5 for CalDAV.
|
||
|
||
### Posture A now, Posture B long-term (locked decision)
|
||
|
||
- **A (this plan):** pure front-end over whatever tasks provider is installed
|
||
(OpenTasks / tasks.org / jtx). Fast to ship; requires a provider app present.
|
||
- **B (later):** bundle the Apache-2.0 `opentasks-provider` so the app is
|
||
self-contained (owns the `org.dmfs.tasks` authority + `org.dmfs.permission.*`,
|
||
DAVx5 syncs directly into it). Bundling the provider bundles **storage, not
|
||
sync** — external CalDAV engines still feed it, which keeps us true to the
|
||
thesis.
|
||
|
||
**The one rule that makes B additive instead of a rewrite:** the entire app
|
||
talks to a `TasksRepository`; only *one* class (`OpenTasksDataSource`) knows
|
||
about a `ContentResolver`, `TaskContract`, or an authority string, and it
|
||
resolves its authority at **runtime** via `ProviderResolver`. In A the resolver
|
||
finds the installed provider; in B it finds our own bundled one. Repo + UI +
|
||
domain never change. **Never let `TaskContract` column names or the authority
|
||
string leak above the data layer.**
|
||
|
||
---
|
||
|
||
## 1. What transfers from Calendula
|
||
|
||
Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
|
||
|
||
| Area | From Calendula | Change for Agendula |
|
||
|---|---|---|
|
||
| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only |
|
||
| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical |
|
||
| Theme | `ui/theme/Theme.kt` (`MaterialExpressiveTheme`, `MotionScheme.standard()`, dynamic color + hand-tuned fallback), `Type.kt`, `Color.kt` | reseed fallback palette |
|
||
| DI shape | `data/di/DataModule.kt` (`DataBindModule` + `DataProvideModule`), `@IoDispatcher` qualifier, DataStore wiring | rename store |
|
||
| Data seam pattern | `CalendarRepository` (Flow API) + `CalendarRepositoryImpl` wrapping a `CalendarDataSource` interface + `AndroidCalendarDataSource` (Cursor/ContentObserver), `Projections`, `ColumnReader`, mappers | retarget to `TaskContract` |
|
||
| Reactive flows | `ContentObserver` → `callbackFlow` bridge in the data source | observe tasks URI |
|
||
| Notifications | `ReminderNotifier` (channel, POST_NOTIFICATIONS gate, dedupe by tag) | reuse almost as-is |
|
||
| Prefs | `data/prefs/SettingsPrefs`, DataStore | reuse |
|
||
| Settings/onboarding/permission UI | `ui/settings`, `ui/permission` (`OnboardingScaffold`, `PermissionScreen`) | adapt copy + permissions |
|
||
| Widgets | Glance `widget/` scaffolding (receiver + `PROVIDER_CHANGED` refresh) | task-list widget |
|
||
| Common UI | `ui/common/*` (GroupedList, InlineTextField, OptionCard, ColorSwatchRow, FailureView, transitions) | reuse |
|
||
| Test stack | JUnit5 + Truth + Turbine + coroutines-test; data source as a JVM-testable seam | reuse |
|
||
|
||
**Net: ~70–80% of the scaffolding is a copy.** The genuinely new code is the
|
||
`TaskContract` data layer, the task screens, and a **self-scheduled reminder
|
||
engine** (see §6 — the one place Calendula's pattern does *not* carry over).
|
||
|
||
### What does NOT exist here (why Agendula is simpler than Calendula)
|
||
|
||
No month/week/day grid rendering. No recurrence-scoped writes ("this & following"
|
||
vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly-
|
||
nested list with a due date and a checkbox.
|
||
|
||
---
|
||
|
||
## 2. Module & package layout
|
||
|
||
Single `:app` module for A (mirrors Calendula). Package root
|
||
`de.jeanlucmakiola.agendula`.
|
||
|
||
```
|
||
domain/
|
||
Models.kt TaskList, Task, TaskStatus, Priority, SubtaskRelation
|
||
TaskForm.kt validated create/edit form (mirrors EventForm)
|
||
Filters.kt smart lists: Today, Upcoming, Overdue, Completed, All
|
||
data/
|
||
di/ Qualifiers.kt, DataModule.kt (copy + retarget)
|
||
tasks/
|
||
TasksContract.kt vendored subset of OpenTasks TaskContract (Apache-2.0)
|
||
ProviderResolver.kt detect installed provider authority + permissions ← the A/B seam
|
||
TaskProjections.kt column lists
|
||
ColumnReader.kt (copy from Calendula)
|
||
TaskMapper.kt cursor row -> domain
|
||
TaskWriteMapper.kt form -> ContentValues
|
||
TasksDataSource.kt interface (JVM-testable seam)
|
||
OpenTasksDataSource.kt ContentResolver/ContentObserver impl
|
||
TasksRepository.kt Flow API (interface)
|
||
TasksRepositoryImpl.kt wraps the data source
|
||
reminders/
|
||
DueReminderScheduler.kt AlarmManager scheduling (NEW — see §6)
|
||
DueReminderReceiver.kt alarm fires -> post notification
|
||
BootRescheduleReceiver.kt + provider-change reschedule
|
||
TaskNotifier.kt (copy ReminderNotifier, retarget)
|
||
prefs/ SettingsPrefs (copy)
|
||
ui/
|
||
theme/ (copy)
|
||
common/ (copy relevant pieces)
|
||
lists/ ListsScreen + ViewModel + UiState (overview of lists/accounts)
|
||
tasklist/ TaskListScreen (one list or a smart list) + VM + UiState
|
||
detail/ TaskDetailScreen + VM + UiState
|
||
edit/ TaskEditScreen + VM + UiState
|
||
settings/ (copy + adapt)
|
||
permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding
|
||
AgendulaHost.kt nav host (mirrors CalendarHost)
|
||
RootScreen.kt
|
||
widget/ Glance task widget (later milestone)
|
||
AgendulaApp.kt, MainActivity.kt
|
||
```
|
||
|
||
---
|
||
|
||
## 3. The data layer (the heart)
|
||
|
||
### 3.1 Provider targeting — `ProviderResolver`
|
||
|
||
Known providers, in preference order, each = (authority, read perm, write perm):
|
||
|
||
| Provider | Authority | Permissions | Contract |
|
||
|---|---|---|---|
|
||
| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks TaskContract |
|
||
| tasks.org | `org.tasks.opentasks` *(verify on device)* | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks-compatible |
|
||
| jtx Board | `at.techbee.jtx` | `at.techbee.jtx.permission.READ` / `WRITE` | jtx (richer, different) |
|
||
|
||
`ProviderResolver.detect()`:
|
||
1. `PackageManager.resolveContentProvider(authority, 0)` for each known authority.
|
||
2. Return the first present as the active `TaskProvider(authority, readPerm, writePerm)`.
|
||
3. None present → `null` → drives the "install a tasks provider" onboarding (§5).
|
||
|
||
**v1 scope:** fully support the **OpenTasks contract** (covers OpenTasks +
|
||
tasks.org's compatible provider). jtx is detected but treated as "supported
|
||
later" (its contract differs). For B, the resolver simply also finds our bundled
|
||
`org.dmfs.tasks`.
|
||
|
||
> Verify the exact tasks.org provider authority on a real device before relying
|
||
> on it — docs are inconsistent; OpenTasks (`org.dmfs.tasks`) is the certain one.
|
||
|
||
### 3.2 `TasksContract.kt`
|
||
|
||
Vendor the subset we use from the Apache-2.0 OpenTasks `TaskContract` (don't take
|
||
a runtime dep on OpenTasks). Authority is injected, not hardcoded. Tables/columns:
|
||
|
||
- **TaskLists**: `_ID`, `LIST_NAME`, `LIST_COLOR`, `ACCOUNT_NAME`, `ACCOUNT_TYPE`,
|
||
`SYNC_ENABLED`, `VISIBLE`, `OWNER`. (Account fields → group lists by account in
|
||
the UI, like Calendula groups calendars.)
|
||
- **Tasks** (the denormalized instances view): `_ID`, `LIST_ID`, `TITLE`,
|
||
`DESCRIPTION`, `DTSTART`, `DUE`, `IS_ALLDAY`, `TZ`, `STATUS`, `PRIORITY`,
|
||
`PERCENT_COMPLETE`, `COMPLETED`, `RRULE`, `LIST_COLOR`, `ACCOUNT_*`.
|
||
- **Properties / Relation** (`Tasks.Properties`, mimetype Relation): subtasks via
|
||
`RELATED-TO` (`RELATION_TYPE_PARENT`). This is how DAVx5 carries the hierarchy.
|
||
|
||
### 3.3 Repository API
|
||
|
||
```kotlin
|
||
interface TasksRepository {
|
||
fun taskLists(): Flow<List<TaskList>>
|
||
fun tasks(filter: TaskFilter): Flow<List<Task>> // list-id or smart list
|
||
suspend fun taskDetail(taskId: Long): TaskDetail
|
||
suspend fun createTask(form: TaskForm): Long
|
||
suspend fun updateTask(taskId: Long, original: TaskForm, updated: TaskForm)
|
||
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
|
||
suspend fun deleteTask(taskId: Long)
|
||
suspend fun createLocalList(name: String, color: Int): Long // device-only list we own
|
||
// subtasks: createSubtask(parentId, form) / reparent via RELATED-TO
|
||
}
|
||
```
|
||
|
||
Writes go through the **sync-adapter URI form** where the provider requires it
|
||
(local/unsynced lists), mirroring Calendula's `createLocalCalendar`. Completion =
|
||
set `STATUS=COMPLETED`, `PERCENT_COMPLETE=100`, `COMPLETED=now`; DAVx5 syncs it
|
||
back out as a normal VTODO status change.
|
||
|
||
### 3.4 Reactive flows
|
||
|
||
`OpenTasksDataSource` registers a `ContentObserver` on the active authority's
|
||
Tasks/TaskLists URIs and emits via `callbackFlow` — identical mechanism to
|
||
Calendula, so external sync (DAVx5 pulling new tasks) updates the UI live, and
|
||
multiple sync sources coexist in one list.
|
||
|
||
---
|
||
|
||
## 4. Screens (Compose, M3 Expressive)
|
||
|
||
1. **Lists overview** (`ui/lists`) — smart lists at top (Today, Upcoming,
|
||
Overdue, All), then user lists grouped by account (reuse `GroupedList`).
|
||
Per-list color dot + open/undone count.
|
||
2. **Task list** (`ui/tasklist`) — the workhorse. Checkbox rows, swipe-to-
|
||
complete + swipe-to-delete, inline "add task" field (reuse `InlineTextField`),
|
||
subtask indentation, sort (due/priority/manual), section headers for smart
|
||
lists (Overdue / Today / Later). FAB to add.
|
||
3. **Task detail** (`ui/detail`) — title, notes, due/start, priority,
|
||
percent-complete, subtasks, source list/account, completed timestamp.
|
||
4. **Task edit** (`ui/edit`) — title, notes, list picker, due/start date-time
|
||
(reuse Calendula date/time pickers), priority, reminder offset, subtasks.
|
||
"Conflict-safe save" like Calendula (re-check before overwrite).
|
||
5. **Settings** (`ui/settings`) — theme/dynamic-color/language (copy), default
|
||
list, default reminder offset, "tasks app" info + manage button.
|
||
6. **Onboarding / permission** (`ui/permission`) — see §5.
|
||
|
||
Material 3 Expressive throughout: `MaterialExpressiveTheme`,
|
||
`MotionScheme.standard()`, expressive checkbox/FAB/swipe motion. Follow the
|
||
`material-3` skill for component choices (M3 `ListItem` for rows, etc.).
|
||
|
||
---
|
||
|
||
## 5. Onboarding & permissions
|
||
|
||
Three gates, in order:
|
||
|
||
1. **Provider present?** `ProviderResolver.detect()`. If none → a screen
|
||
explaining Agendula needs a tasks provider, with one-tap links to install
|
||
**OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app".
|
||
(This is Agendula's "needs DAVx5" moment. Disappears entirely under Posture B.)
|
||
2. **Tasks read/write permission** — request the active provider's runtime perms
|
||
(`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at
|
||
runtime; resolved dynamically from the detected provider.
|
||
3. **POST_NOTIFICATIONS + exact-alarm** — for due reminders (reuse Calendula's
|
||
reminder onboarding).
|
||
|
||
`<queries>` in the manifest for package visibility (launch DAVx5 / OpenTasks),
|
||
exactly like Calendula's launcher query.
|
||
|
||
---
|
||
|
||
## 6. Reminders — the one pattern that does NOT carry over
|
||
|
||
Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and
|
||
just posts the notification (Etar model). **Tasks providers do not broadcast
|
||
reminders.** So Agendula must schedule its own:
|
||
|
||
- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` /
|
||
`SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen
|
||
offset (and optionally at `DTSTART`).
|
||
- `DueReminderReceiver` fires → posts via `TaskNotifier` (the copied
|
||
`ReminderNotifier`), tapping opens the task.
|
||
- **Reschedule triggers:** boot (`RECEIVE_BOOT_COMPLETED`), and the data-source
|
||
`ContentObserver` firing on any task change (our edits *and* external sync) →
|
||
recompute alarms for the near-future window. Keep a small scheduled-alarm
|
||
registry in DataStore so we can diff like Calendula diffs reminder rows.
|
||
|
||
This is the single largest *new* subsystem. Everything downstream of it (channel,
|
||
notification building, POST_NOTIFICATIONS gating, dedupe-by-tag) is copied.
|
||
|
||
---
|
||
|
||
## 7. Manifest (vs Calendula)
|
||
|
||
```xml
|
||
<!-- declared statically; the active set is also requested at runtime -->
|
||
<uses-permission android:name="org.dmfs.permission.READ_TASKS"/>
|
||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS"/>
|
||
<uses-permission android:name="org.tasks.permission.READ_TASKS"/>
|
||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS"/>
|
||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
|
||
<queries> … LAUNCHER intent (package visibility) … </queries>
|
||
```
|
||
Receivers: `DueReminderReceiver`, `BootRescheduleReceiver`, Glance widget
|
||
receiver. **No `EVENT_REMINDER` receiver** (doesn't apply).
|
||
|
||
---
|
||
|
||
## 8. Milestones
|
||
|
||
- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from
|
||
Calendula, rename to Agendula. Builds, shows themed empty scaffold. *(~½ day)*
|
||
- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource`
|
||
+ repository. Lists overview + task list render real synced tasks (read-only),
|
||
live-updating via ContentObserver. *(the meaty milestone)*
|
||
- **M2 — Complete & CRUD.** Toggle complete (swipe + checkbox), create via inline
|
||
field + edit screen, delete. Local-list creation.
|
||
- **M3 — Detail/edit polish.** Due/start pickers, priority, percent, conflict-safe
|
||
saves, smart lists (Today/Upcoming/Overdue).
|
||
- **M4 — Subtasks.** `RELATED-TO` read + indentation; create/reparent. *(trickiest)*
|
||
- **M5 — Reminders.** `DueReminderScheduler` + receivers + onboarding.
|
||
- **M6 — Settings, widget, i18n, F-Droid metadata, CI** (clone Calendula's
|
||
`.gitea/workflows`, `fdroid-metadata/`, RELEASING docs).
|
||
- **B (separate, later):** add `:provider` module bundling Apache-2.0
|
||
`opentasks-provider`; `ProviderResolver` default authority becomes our own
|
||
`org.dmfs.tasks`; add sync-adapter permissions; ship self-contained. UI/repo
|
||
untouched.
|
||
|
||
**Effort:** M0–M3 is a small, fast core (days, given the Calendula head start).
|
||
M4 (subtasks) and M5 (reminders) are the two spots needing real thought.
|
||
|
||
---
|
||
|
||
## 9. Open decisions / to verify
|
||
|
||
1. ~~**Name**~~ — **Agendula** (final): *agenda* + Calendula's `-ula`. Renamed
|
||
from the working title *Floret*, which was promoted to the family /
|
||
design-language name.
|
||
2. **tasks.org provider authority** — verify on a real device; OpenTasks
|
||
(`org.dmfs.tasks`) is the certain target for v1.
|
||
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
|
||
4. **B authority choice** — bundling `org.dmfs.tasks` makes Agendula a *replacement*
|
||
for OpenTasks (one authority owner per device). Intended (one app instead of
|
||
two), but a conscious choice.
|
||
5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI.
|
||
|
||
---
|
||
|
||
## 10. Sync sources Agendula inherits for free (README copy)
|
||
|
||
Because Agendula builds on the provider, not on any one sync app, it works with
|
||
**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync,
|
||
CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration.
|
||
Google Tasks / Microsoft To Do are out of scope by design (proprietary, would
|
||
mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are
|
||
the lane.
|