M0: Floret skeleton — Material 3 Expressive scaffold over OpenTasks (planned)
All checks were successful
CI / ci (push) Successful in 6m39s
All checks were successful
CI / ci (push) Successful in 6m39s
Sibling to Calendula. Pure front-end posture; TaskContract data layer, task screens and reminder engine to follow (see docs/PLAN.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
319
docs/PLAN.md
Normal file
319
docs/PLAN.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Floret — 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`;
|
||||
> Floret is a to-do list over the **OpenTasks `TaskContract` provider**. A
|
||||
> Calendula flower head is made of many small *florets* — the individual items
|
||||
> that make up the bloom.
|
||||
|
||||
Working identifiers (placeholder, easily renamed):
|
||||
`applicationId = de.jeanlucmakiola.floret`, app name **Floret**.
|
||||
|
||||
---
|
||||
|
||||
## 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. Floret
|
||||
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 Floret |
|
||||
|---|---|---|
|
||||
| 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 Floret 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.floret`.
|
||||
|
||||
```
|
||||
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
|
||||
FloretHost.kt nav host (mirrors CalendarHost)
|
||||
RootScreen.kt
|
||||
widget/ Glance task widget (later milestone)
|
||||
FloretApp.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 Floret 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 Floret'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 Floret 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 Floret. 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** — `Floret` is the working title (florets compose a Calendula head).
|
||||
Confirm or replace before M0 (touches appId, namespace, package).
|
||||
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 Floret 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 Floret inherits for free (README copy)
|
||||
|
||||
Because Floret 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.
|
||||
Reference in New Issue
Block a user