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>
13 KiB
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; for status and what's next, see 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,
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 aContentResolver, aTaskContract, 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;AndroidTasksDataSourceis 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
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.idis the instance row id;Task.taskIdis the underlyingtasks._idand the stable target for edits/completion.- Subtasks are carried via
parentId(RELATED-TO/RELATION_TYPE_PARENT);TaskDetailbundles 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 inModels.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 asdue − lead(lead fromSettingsPrefs), and diffs againstScheduledReminderStoreso only changed alarms move. It bails and clears everything if the provider is absent or unpermissioned.- Alarms are exact where allowed (
setExactAndAllowWhileIdle, falling back tosetwhencanScheduleExactAlarms()is false), keyed bytaskId. DueReminderReceiverfires → posts viaTaskNotifier(channel,POST_NOTIFICATIONSgate, dedupe-by-tag).- Re-sync triggers: app start,
BootReceiver(re-arm after reboot), andProviderChangeReceiver(PROVIDER_CHANGEDon 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
:providermodule bundling the Apache-2.0opentasks-provider.ProviderResolverthen finds our ownorg.dmfs.tasksfirst; external CalDAV engines sync directly into it. The UI, ViewModels, domain, andTasksRepositorydo 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). 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. |
| CI | Gitea workflows (.gitea/workflows/ci.yaml, release.yaml) |
| Distribution | F-Droid (fdroid-metadata/) |
11. Manifest surface
- Permissions: both
org.dmfs.*andorg.tasks.*read/write tasks perms declared statically (the active set is requested at runtime);POST_NOTIFICATIONS,RECEIVE_BOOT_COMPLETED, exact-alarm (USE_EXACT_ALARMon 33+,SCHEDULE_EXACT_ALARM≤32). <queries>for package visibility: both provider authorities + a LAUNCHER intent (soresolveContentProviderworks and onboarding can open the provider / a store listing).- Receivers:
DueReminderReceiver(not exported),BootReceiver,ProviderChangeReceiver(both authorities). NoEVENT_REMINDERreceiver — that's a Calendula thing that doesn't apply here.