Files
agendula/docs/PLAN.md
Jean-Luc Makiola 76a0139fda
All checks were successful
CI / ci (push) Successful in 8m33s
Rename app Floret → Agendula
Floret is promoted to the family / design-language (shared-kit) name; the
tasks app itself becomes Agendula (de.jeanlucmakiola.agendula) — agenda
('things to be done') + Calendula's -ula, a twin of the Calendula name.

Renames the package, namespace, applicationId, rootProject.name, app_name,
FloretApp/FloretNavHost/FloretTransitions classes, theme, F-Droid metadata
dir, CI artifact name, and docs. The botanical word 'florets' is preserved in
the name-origin prose, which is rewritten to Agendula's etymology. Clean
build + unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:10:24 +02:00

323 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agendula — implementation plan
> 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: ~7080% 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:** M0M3 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.