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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user