Files
agendula/docs/ARCHITECTURE.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

264 lines
13 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 — architecture
This document describes how Agendula is built **as it stands today**. For the
*why* behind the big decisions and the long-term plan, see
[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md).
---
## 1. The thesis in one sentence
Agendula is a Material 3 Expressive **front-end** over the OpenTasks
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
which does the same thing for `CalendarContract`.
The whole design hangs off one rule:
> The entire app talks to a `TasksRepository`. Only the data layer knows there
> is a `ContentResolver`, a `TaskContract`, or an authority string behind it.
> **Provider column names and the authority string never leak above the data
> layer.**
This is what lets "Posture A" (front-end over an installed provider) become
"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching
the UI, the ViewModels, or the domain. See §7.
---
## 2. Layers
```
┌──────────────────────────────────────────────┐
UI │ Compose screens + ViewModels (ui/*) │
│ RootScreen → permission gate → ListsScreen │
└───────────────┬──────────────────────────────┘
│ domain models + Flows only
┌───────────────▼──────────────────────────────┐
Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
│ DayWindow (pure Kotlin, no Android) │
└───────────────┬──────────────────────────────┘
│ TasksRepository (interface)
┌───────────────▼──────────────────────────────┐
Data │ TasksRepositoryImpl │
│ └ TasksDataSource (interface) │
│ └ AndroidTasksDataSource │
│ └ ContentResolver / TaskContract / │
│ ProviderResolver / ContentObserver│
│ reminders/ prefs/ di/ demo/ │
└───────────────┬──────────────────────────────┘
│ content:// + dangerous perms
┌───────────────▼──────────────────────────────┐
External │ OpenTasks provider ←sync← DAVx5 / DecSync… │
└──────────────────────────────────────────────┘
```
The seam that matters is the pair of interfaces in the data layer:
- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
writes. (`data/tasks/TasksRepository.kt`)
- **`TasksDataSource`** — the JVM-testable interface that does the actual
provider work; `AndroidTasksDataSource` is the only Android-coupled
implementation.
Both are bound in Hilt in `data/di/DataModule.kt`.
---
## 3. Module & package layout
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
| Package | Contents |
|---|---|
| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. |
| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
| `data/prefs/` | `SettingsPrefs` (DataStore). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
---
## 4. The data layer (the heart)
### 4.1 Provider targeting — `ProviderResolver`
`ProviderResolver.resolve()` walks a preference-ordered candidate list and
returns the first provider actually installed (via
`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate
is a `TaskProvider(authority, readPermission, writePermission, packageName)`.
| Provider | Authority | Permissions |
|---|---|---|
| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract`
columns apply** regardless of which is present. `null` from `resolve()` drives
the "install a tasks provider" onboarding gate. `hasPermission()` checks both
runtime perms for the active provider.
### 4.2 `TasksContract`
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
table paths, status/priority constants, the local-account type. Agendula does
**not** take a runtime dependency on OpenTasks; the authority is injected from
`ProviderResolver`, never hardcoded in the contract.
### 4.3 Reads — Instances + `ContentObserver`
`AndroidTasksDataSource` queries the denormalized **instances** view (so each
occurrence is a row with the joined list colour, account, etc.), maps each
cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes
the result as a `Flow`. A `ContentObserver` on the active authority's
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
update the UI live, and multiple sync sources coexist in one list.
### 4.4 Writes — repository API
```kotlin
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm)
suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long
fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
}
```
`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion
sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back
out as a normal VTODO status change. Writes to local/unsynced lists use the
sync-adapter URI form where the provider requires it.
### 4.5 Domain model notes
- `Task.id` is the **instance** row id; `Task.taskId` is the underlying
`tasks._id` and the stable target for edits/completion.
- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
`TaskDetail` bundles a task with its direct children.
- `effectiveColor` = the task's own colour, else the list colour.
- iCal priority is bucketed to `NONE/LOW/MEDIUM/HIGH`; status maps to a
4-value enum. Both mappings are pure functions in `Models.kt`, unit-tested.
---
## 5. Smart lists, filtering, sorting
`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it
unit-tests with a fixed clock. `TaskSorting` orders within a list (due /
priority / etc.). None of this touches Android, which is why it's all in
`domain/`.
---
## 6. Reminders — the one subsystem that does NOT mirror Calendula
Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks
providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`):
- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks
within a rolling **30-day window**, computes each trigger as `due lead`
(lead from `SettingsPrefs`), and **diffs against `ScheduledReminderStore`** so
only changed alarms move. It bails and clears everything if the provider is
absent or unpermissioned.
- Alarms are exact where allowed (`setExactAndAllowWhileIdle`, falling back to
`set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
`POST_NOTIFICATIONS` gate, dedupe-by-tag).
- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and
**`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external
sync changed the data). The store lets each run diff like Calendula diffs
reminder rows.
This is the single largest piece of genuinely-new code in Agendula.
---
## 7. The A / B seam (why the layering is shaped this way)
- **Posture A (today):** front-end over whatever provider is installed. Ships
fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding
moment).
- **Posture B (later):** add a `:provider` module bundling the Apache-2.0
`opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks`
first; external CalDAV engines sync directly into it. **The UI, ViewModels,
domain, and `TasksRepository` do not change** — only the resolver's default and
some manifest perms.
Bundling the provider bundles **storage, not sync** — Agendula stays a pure
front-end over open backends either way.
---
## 8. UI
Compose + Material 3 **Expressive** (`MaterialExpressiveTheme`,
`MotionScheme.standard()`, dynamic colour with a hand-tuned warm-mauve
fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
`edit`, `settings`, `permission`) has a ViewModel + immutable `UiState`;
`ListsScreen` is the first rendered surface.
`RootScreen` is the entry composable: it gates on `ProviderStatus`
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY`
`ListsScreen`). The remaining screens are being built one at a time — their
ViewModels exist and are tested against the real data layer; navigation
callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the
`material-3` skill for component choices (M3 `ListItem` rows, expressive
checkbox/FAB/swipe motion).
---
## 9. Dependency injection
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository`
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
---
## 10. Build & tooling
| | |
|---|---|
| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| Versioning | git tag is the source of truth; `versionCode = MAJOR*10000 + MINOR*100 + PATCH`, derived in CI at release. See [`RELEASING.md`](RELEASING.md). |
| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) |
| Distribution | F-Droid (`fdroid-metadata/`) |
---
## 11. Manifest surface
- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
declared statically (the active set is requested at runtime);
`POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
(`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32).
- **`<queries>`** for package visibility: both provider authorities + a LAUNCHER
intent (so `resolveContentProvider` works and onboarding can open the
provider / a store listing).
- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`,
`ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver —
that's a Calendula thing that doesn't apply here.