feat(settings): storage picker and export screen

The store picker and the export screen were the two frontend surfaces
the own-store work left unbuilt, so both backends shipped unreachable.
Settings gains a Storage section holding them: a full-screen picker over
Own / an installed external provider (dimmed when none is present, named
after the provider's own app), and an export screen with a per-list tick
and the two SAF destinations, a folder or a single zip. The picker asks
for the provider's runtime permission before writing the mode, so a
denial leaves the readable store in place instead of dropping the user on
the gate; a refusal is reported with a route to app settings.

Making the mode switchable at runtime had two consequences:

- reminders are armed off whichever store was active when they were
  scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is
  now serialised — it is a read-modify-write over ScheduledReminderStore,
  and overlapping runs each wrote their own set as the whole truth
- the permission gate is the only screen an External user can reach once
  their provider app stops answering, so it offers the way back to our
  own store

ExportWriter no longer deletes a previous export before recreating it (a
failure in between lost both), lists the target directory once instead of
per document, and carries a typed ExportFailure so the screen can report
in the user's language rather than an exception message.
This commit is contained in:
2026-09-04 15:37:48 +02:00
parent 9ff6027e50
commit ec2e2eb59d
17 changed files with 845 additions and 79 deletions

View File

@@ -108,7 +108,7 @@ postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root
| `data/prefs/` | `SettingsPrefs` (DataStore). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. |
| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/` (hub + sub-screens, `StorageScreen` among them), `export/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
---
@@ -447,10 +447,21 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
`RootScreen` is the entry composable: it gates on `ProviderStatus`
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
only ever seen in External mode. Routes are the `Dest` table in
`ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the
`material-3` skill for component choices (M3 `ListItem` rows, expressive
checkbox/FAB/swipe motion).
only ever seen in External mode — and it offers a way back to our own store,
because it is the only screen an External user can reach once their provider app
stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task
list → detail / edit, plus settings). Follow the `material-3` skill for component
choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion).
Settings is a hub of sliding sub-screens rather than routes; **Storage** is the
one with teeth. It holds the §4.1 store picker — which asks for an external
provider's runtime permission *before* writing the mode, so a denial leaves the
readable store in place instead of stranding the user on the gate — and the
export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a
folder or a single zip). Because the mode is now switchable while the process
lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an
alarm is scheduled off whichever store was active at the time, so the whole set
has to be rebuilt against the new one.
---

View File

@@ -19,10 +19,11 @@ iCalendar has landed, and the Material 3 Expressive UI is built through **M5**:
lists → task list (swipe gestures, inline add, smart-list section headers) →
detail / edit with full CRUD, date-time pickers, priority, percent-complete,
conflict-safe saves, per-task reminders, and subtask create + reparent — plus a
one-time reminder onboarding step and a Settings screen. Remaining work is
hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for
what has landed** (a storage-mode picker, an export screen), then M6 (Glance
widget, translations, F-Droid release) and the sync adapter.
one-time reminder onboarding step and a Settings screen. The frontend surfaces
for the new store have landed too: a Settings **Storage** section with the
store picker and an **export screen**. Remaining work is verifying all of it on
a device, then M6 (Glance widget, translations, F-Droid release) and the sync
adapter.
---
@@ -147,8 +148,17 @@ what Posture B means (our own store, coexisting with everything — *not* squatt
- ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives
only in our app's private storage. One `.ics` per list, to a folder or a zip,
via SAF. Backend only.
- **Frontend surfaces for the above**a storage-mode picker in Settings and
an export screen. The backend is done and unused until these exist.
- **Frontend surfaces for the above**Settings gained a **Storage** section
holding both: a full-screen store picker (Own / an installed external provider,
which is dimmed when none is present) that asks for the provider's runtime
permission *before* committing the switch, and an export screen with a per-list
tick and the two SAF destinations, a folder or a single zip. Two consequences
of the mode becoming switchable at runtime came with it: reminders are re-armed
against the new store on every switch (`AgendulaApp` listens on
`ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit
resynced them), and the permission gate offers a way back to our own store —
otherwise a user whose provider app went away is held on a gate with Settings
behind it.
- ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users.
Note it now means "sync into an app that has no provider", so the ask has
changed shape.
@@ -264,9 +274,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
resolved: our own store expands a series at read time and writes an edit to
one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in
External mode the edit still goes through the instances URI.
6. **Resolver ordering / mode-selection UX** `autoMode()` picks a sane default
today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it
assumes is not built yet.
6. ~~**Resolver ordering / mode-selection UX**~~ resolved: `autoMode()` picks the
default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage
→ Task store is the override it always assumed.
7. ~~**Sync protocol coverage**, account model, conflict resolution — the next
design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens
live on that document's list.

View File

@@ -42,7 +42,7 @@
|---|---|---|---|
| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done |
| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done |
| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet |
| 3 | Export / backup | our data now lives only in our app's private storage | ✅ done, UI included |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ |
| 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ |
@@ -202,8 +202,9 @@ than "rank ours first whenever it's non-empty", and needs no database probe:
That permission is dangerous-level, so it can only be there because an earlier
version asked and the user agreed — which is exactly what "existing Posture A
user" means. A fresh install holds nothing and gets local-first. The Settings
override the rule assumes is not built yet.
user" means. A fresh install holds nothing and gets local-first. The Settings
override the rule assumes is built: Storage → Task store, which asks for the
external provider's permission before committing the switch rather than after.
**Note on the mode vocabulary.** The code has two modes, not three:
`StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two
@@ -211,7 +212,7 @@ paragraphs up, Synced *is* Local with an account attached — so it is derived
state, and giving it its own constant would imply switching sync on is a
migration when the whole point is that it isn't.
**Export/backup is a v1 feature.** ✅ Backend built (no UI yet). Not, as
**Export/backup is a v1 feature.** ✅ Built, screen included. Not, as
previously framed, a migration safety net for "uninstall OpenTasks" — that
scenario no longer exists. It's data portability for Local-mode users, whose
tasks otherwise exist in exactly one place with no second copy. On Play, where