docs: add architecture, roadmap, releasing, contributing + docs index
All checks were successful
CI / ci (push) Successful in 5m34s

Add a grounded doc set under docs/ plus a root CONTRIBUTING.md:
- docs/ARCHITECTURE.md: current code shape (layers, data seam, provider
  resolution, reminder engine, DI, build/tooling, manifest)
- docs/ROADMAP.md: status view (M0/M1 done, M2 in progress, open decisions)
- docs/RELEASING.md: tag-driven release flow, CI jobs, F-Droid repo, secrets
  (was referenced by build.gradle.kts and CHANGELOG but missing)
- docs/README.md: docs index and how the docs relate
- CONTRIBUTING.md: build/test/lint, layer rules, style, PR conventions

Also refresh the stale "M0 — skeleton" status note in README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 08:49:05 +02:00
parent 436c567362
commit b784a77329
6 changed files with 595 additions and 4 deletions

92
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,92 @@
# Contributing to Floret
Thanks for your interest in Floret — a Material 3 Expressive task app that's a
pure front-end over the OpenTasks `TaskContract` provider, with no own database
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
practical how.
## The one architectural rule
Everything above the data layer talks to `TasksRepository` and sees only domain
types and Flows. **Provider column names, `TaskContract`, `ContentResolver`, and
the authority string never leak above `data/tasks/`.** This is what keeps
"Posture B" (bundling the provider later) an additive change instead of a
rewrite — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §7. If a change
would expose provider details to a ViewModel or the UI, it's in the wrong layer.
## Prerequisites
- JDK 17
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
- A device or emulator with **OpenTasks** or **tasks.org** installed for
anything touching the read/write paths (ideally with DAVx5 syncing a CalDAV
task list, so there's real data). Debug builds fall back to `DemoSeeder` for
sample data.
## Build, test, lint
```sh
./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every push)
```
CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every
push, so run these locally before opening a PR. Keep CI green.
## Where to put code
| Layer | Lives in | Rule of thumb |
|---|---|---|
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
| Provider access | `data/tasks/` | The only place that knows about the provider. New provider work goes through `TasksDataSource`. |
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
| Screens | `ui/<area>/` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
## Code style & conventions
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
Match the surrounding code.
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
colour-scheme tokens (never hardcoded colours), and canonical M3 components
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
new screen or component.
- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
the only Android-coupled data implementation so the rest stays JVM-testable.
## Tests
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
come with JVM unit tests under `app/src/test/`. The data source is the
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
- Add an instrumented test only when a path genuinely needs a real
`ContentResolver`.
## Commits & PRs
- Write focused commits with clear messages (the existing history uses short,
scoped subjects like `UI: ...` / `M1 ...`).
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
user-visible change — its sections feed the release notes and F-Droid "What's
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
- If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR.
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at
release time.
## Scope
Floret stays true to its thesis: a front-end over **open** task backends
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
richer contract is a possible later addition.
## License
By contributing you agree your contributions are licensed under the project's
[MIT License](LICENSE).

View File

@@ -22,10 +22,13 @@ database, no reinvented sync.
A Calendula flower head is botanically made of many small *florets* — the
individual items that make up the bloom. Floret is those items: your tasks.
> **Status: M0 — skeleton.** Builds and shows a themed placeholder. The
> `TaskContract` data layer, task screens, and reminder engine are the next
> milestones. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap and the
> A-now-B-later architecture (front-end first; bundle the provider later).
> **Status: data layer done, UI in progress.** The full non-visual stack over
> the `TaskContract` provider — provider resolution, live-updating reads,
> writes, smart-list filtering, and a self-scheduled reminder engine — is built
> and unit-tested. The Material 3 Expressive screens are now being built on top,
> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
## Sync sources (by design)

263
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,263 @@
# Floret — architecture
This document describes how Floret 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
Floret 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. **Floret 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.floret`.
| 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 | `FloretApp` (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. Floret 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** — Floret'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 Floret 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 Floret.
---
## 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** — Floret 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 `floret_prefs` DataStore,
the `@IoDispatcher`). `FloretApp` 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.

27
docs/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Floret — documentation
Floret is a Material 3 Expressive **task** app for Android: a pure front-end over
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
over CalDAV), with no own database or sync stack. Sibling to
[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
top-level [`../README.md`](../README.md) for the project pitch.
## Index
| Doc | What it covers |
|---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Floret is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. |
| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |
Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections
feed the release notes).
## How the docs relate
- **PLAN** is the design decisions (mostly stable; the "why").
- **ARCHITECTURE** is the current shape of the code (kept in sync with the
source as it grows).
- **ROADMAP** is the moving status layer (update as milestones land).
- **RELEASING** is the operational runbook.

101
docs/RELEASING.md Normal file
View File

@@ -0,0 +1,101 @@
# Floret — releasing
Floret is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a
human-readable **Gitea release** per tag. Both are produced automatically by
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on
the Gitea release itself — distribution lives in the F-Droid repo; the release is
the changelog of record.
---
## The one source of truth: the git tag
The git tag drives the version. You do **not** hand-edit version numbers for a
release — CI substitutes them from the tag:
- `versionName` = the tag without a leading `v` (e.g. `v0.2.0``0.2.0`).
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0``200`,
`1.3.4``10304`).
The values committed in `app/build.gradle.kts` are just the local/dev default;
keep them roughly matching the latest released tag, but the tag wins at release
time.
---
## Cutting a release
1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new
`## [X.Y.Z]` section. The release pipeline extracts everything between
`## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the
Gitea release notes and the F-Droid per-version "What's New"
(`changelogs/<versionCode>.txt`). If no matching section exists, a fallback
line is used — so the heading **must** match the tag's version exactly.
2. **Commit** the changelog on `main`.
3. **Tag and push:**
```sh
git tag v0.2.0
git push origin v0.2.0
```
4. CI takes over (see below). Watch the run in Gitea Actions.
---
## What CI does on a tag
`release.yaml` runs three jobs:
| Job | Purpose |
|---|---|
| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. |
| `build-and-deploy` | Substitute version from the tag → build signed release APK → fetch the existing F-Droid repo from Hetzner → add the new APK + per-version changelog → `fdroid update -c` → upload `repo/` + `metadata/` back. Also attaches the R8 `mapping.txt.gz` to the Gitea release (best-effort) so crash stacktraces stay deobfuscatable. |
| `gitea-release` | Create/update the Gitea release for the tag, body = the extracted CHANGELOG section. Gated on `ci` only (not deploy), so notes still publish if the F-Droid upload hiccups. |
Both deploy and release steps are **re-run safe** (idempotent upserts), so a
failed run can be retried.
---
## Required CI secrets
Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid
ones are missing (it will **never** auto-generate a repo key — that would rotate
the repo fingerprint and break every user's pinned repo).
| Secret | Used for |
|---|---|
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). |
| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. |
| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). |
The repo signing key and `config.yml` come from secrets at build time and are
**never** pulled from or pushed back to the server, so they can't leak into the
web-served tree (nginx serves only `repo/`).
---
## Key rotation / repo recovery
A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the
tag-only build steps: it just re-signs the existing index with the configured
repo key and re-uploads. Use this to recover the repo or rotate infrastructure
**without** publishing a new APK.
---
## Push CI (non-tag)
Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` →
`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`.
Keep this green before tagging.
---
## F-Droid metadata
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.floret.yml`
plus `en-US/` summary/description). Per-version changelogs are generated into the
repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at
release time; metadata is uploaded alongside `repo/` so changelog history
survives across releases.

105
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,105 @@
# Floret — 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)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being
built screen by screen on top of it. App builds, gates on provider/permission,
and renders the lists overview.
---
## 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
Replace the functional scaffold with the real Material 3 Expressive UI, screen
by screen, against the already-built ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in
`RootScreen` are currently stubs).
- ⬜ Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete,
inline add field, section headers for smart lists, FAB.
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list
creation wired to the UI.
### ⬜ M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority,
percent-complete, conflict-safe saves (re-check before overwrite), smart-list
section presentation.
### ⬜ M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The
data layer already reads `parentId`; this is the trickiest UI piece.
### ⬜ M5 — Reminders onboarding & polish
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm
onboarding flow, per-task / default reminder-offset settings UI, and
end-to-end verification on device.
### ⬜ M6 — Settings, widget, i18n, release
Settings screen (theme / dynamic-color / language, default list, default
reminder), Glance task-list widget, translations, finalize F-Droid metadata,
confirm CI release flow.
### ⬜ Posture B (separate track, later)
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter
permissions; ship self-contained. UI / repository / domain untouched — see
[`ARCHITECTURE.md`](ARCHITECTURE.md) §7.
---
## Open decisions / to verify
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`).
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.)
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Floret a
*replacement* for OpenTasks (one authority owner per device). Intended, but a
conscious choice.
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);
recurrence-aware editing is out of scope for v1.
---
## How to contribute / verify
- Build: `./gradlew :app:assembleDebug`
- Unit tests: `./gradlew :app:testDebugUnitTest`
- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and
ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real
data. Debug builds use `DemoSeeder` for sample data when no provider data is
present.