From b784a77329ec8f5fa0fc1eb926d28f703a5709c4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 08:49:05 +0200 Subject: [PATCH] docs: add architecture, roadmap, releasing, contributing + docs index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CONTRIBUTING.md | 92 +++++++++++++++ README.md | 11 +- docs/ARCHITECTURE.md | 263 +++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 27 +++++ docs/RELEASING.md | 101 +++++++++++++++++ docs/ROADMAP.md | 105 +++++++++++++++++ 6 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/README.md create mode 100644 docs/RELEASING.md create mode 100644 docs/ROADMAP.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..417c7b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -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//` | 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). diff --git a/README.md b/README.md index 1b343c0..cce585c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f51ac2c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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> + fun tasks(filter: TaskFilter): Flow> + fun taskDetail(taskId: Long): Flow + + 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). +- **``** 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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..98a26a2 --- /dev/null +++ b/docs/README.md @@ -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 (M0–M6 + 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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..e26130e --- /dev/null +++ b/docs/RELEASING.md @@ -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/.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/.txt` from `CHANGELOG.md` at +release time; metadata is uploaded alongside `repo/` so changelog history +survives across releases. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..38f5c16 --- /dev/null +++ b/docs/ROADMAP.md @@ -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.