SPEC v0.26: D-127 pull-to-refresh on Home and account detail
AC-15.3, §9.9 and §13.3 (S-4, S-5) name the pull as a sync now, not
disabled offline. design-spec: S-4's "Pull-to-refresh is not offered"
replaced; S-4/S-5 refreshing frames, interactions and TalkBack;
KvittPullToRefresh in §3.6; ContainedLoadingIndicator row in §3.1;
spatial fast and the named motion in §2.11. HANDOFF: D-127 landed
(3778f5f), Pi live, both phones paired, import done; next free D-129 /
v0.28 after the parallel D-128 (v0.27).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.25** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.26** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
|
||||
|
||||
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
|
||||
|
||||
@@ -271,7 +271,7 @@ The server starts with an empty database. The household is created from the comm
|
||||
|
||||
* AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2).
|
||||
* AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection".
|
||||
* AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028).
|
||||
* AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). A pull-to-refresh on Home (S-4) or account detail (S-5) also runs a sync, the same call as S-16 "Synka nu" (D-127). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028).
|
||||
* AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13).
|
||||
* AC-15.5 Conflicts resolve by last-write-wins per row on the client `updated_at`, deletes are final, and rejected changes are shown to the user (§14.4).
|
||||
* AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock).
|
||||
@@ -800,7 +800,7 @@ The server validates icon and colour against these lists (`validation_failed`).
|
||||
|
||||
### 9.9 Online-only actions
|
||||
|
||||
When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080). A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally.
|
||||
When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080). A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. Pull-to-refresh on S-4 and S-5 is not disabled offline; it ends at once without a sync (D-127).
|
||||
|
||||
### 9.10 Other edge cases
|
||||
|
||||
@@ -1379,8 +1379,8 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
|
||||
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled; in release builds with `RELEASE_SERVER_URL`, which `:feature:onboarding` receives through the Hilt qualifier `@ReleaseServerUrl` for the prefill and for the non-default-server check of AC-2.6, D-121); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
|
||||
| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue |
|
||||
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings |
|
||||
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account |
|
||||
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) |
|
||||
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account; Pull to refresh (sync now, D-127) |
|
||||
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127) |
|
||||
| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel |
|
||||
| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), `onSaved` handing `:app` the string resource id (D-095); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) |
|
||||
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: <template>" when generated; timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion; "missing" state (not yet on this phone) with "Synka nu" (`expense_detail_sync_now`), online only (§9.9, D-080); after a delete `:app` shows `snackbar_expense_deleted` on the screen returned to (D-095) | Edit; Delete |
|
||||
@@ -2887,3 +2887,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
|
||||
| 0.23 | 2026-10-19 | Port 443 (`a86ad24` server and contracts, `4852803` bundle, `038c603` Android, `ad35231` portless Android fixtures, 30 S-21/S-22 goldens re-recorded) and the pinned-root template (`038c603`, no decision). D-124 (owner decision 2026-10-19): Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts: OpenAPI server `https://{host}`; E-7 and E-12 examples without a port; `tools/contracts-check.py` expects the portless header. Bundle: `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`; health checks through NPM on 443; README router step "no new rule"; the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android: `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 intent filter has no port and matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443`, `:80`); S-1 goldens on `https://kvitt.example.org` (28 re-recorded) (§1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §11.4, §14.2.4, §14.3, E-7, E-12). Without a decision id: the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, only when `kvitt.tls.pinnedRoot=true`, and the build stops while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (§8.5, §10.5.3). |
|
||||
| 0.24 | 2026-10-20 | The self-contained Pi compose project (`6e5981d`), the `.env` without informational keys (`200c99f`) and the sparse-checkout workflow (`3820f9c`, no decision), with the workflow's first results; the compose deploy hint in the workflow and `infra/build-images.sh` (`28fa71b`, no decision). D-125 (owner decision 2026-10-20, revises D-103, D-105, D-117 point 5, D-120 point 6): the Kvitt Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project (`compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`); both containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi) with a read-only root and a 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; each mounts only its own secret file read-only (`create_host_path: false`), secret files the owner's, mode 0600; `.env` holds exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; no install script, no `/opt`, no Docker daemon or system changes (Docker and the registry trust are prerequisites; private packages mean a one-time `docker login gitea.lan`); the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, their equivalents plain `docker compose` commands (backup `exec backup /backup.sh`, bootstrap and partner invite through `exec server kvitt-server`, deploy = backup, set `KVITT_TAG`, `pull`, `up -d`, rollback = the previous tag, APK = `scp` of the APK and then `latest.json` into `data/apk/`, old APKs removed by hand, digests with `docker image inspect`, no digest record, health checks by hand); restore moves files on the host, only rclone and sqlite3 run in the backup container; the compose default network; the development stack unchanged (uid 10001); rehearsed with Podman as uid 501 (§8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 OQ-3, §14.7). Without a decision id: the `.env` keys `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped (`200c99f`, revises D-103), `<KVITT_PI_HOST>` is now `<kvitt-pi-address>` (§8.9, §10.1, §10.5.1, §10.5.3, OQ-3); the Gitea workflow's results on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (a misnamed user-level secret, fixed by the owner 2026-10-19), run 69 built `v1.0.0` (`kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min, first build with `CARGO_BUILD_JOBS=1`), the sparse-checkout push built `v1.0.1`, the tag the Pi uses; the checkout is a cone-mode sparse checkout of `server` and `infra/backup` with `fetch-depth: 0` and `--filter=blob:none`; the diagnostic step removed (§10.3, §11.2). |
|
||||
| 0.25 | 2026-10-21 | The app gate (D-126, owner decision 2026-10-21; Android `34e5cad`): outside the home LAN `192.168.1.0/24` NPM closes the connection (`return 444`) of every request without `X-Kvitt-App: <key>`, every route included (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*`); only `/.well-known/acme-challenge/` passes (Let's Encrypt renewal), tested on `$uri`; the LAN check reads `$realip_remote_addr`, because NPM's `real_ip_header X-Real-IP` for private peers makes `$remote_addr` forgeable; the server and `contracts/openapi.yaml` are unchanged, the bearer token stays the authentication. The app sends the header in `KvittApi`'s common headers and `PictureAuth` when built with `kvitt.appKey` (Gradle property, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey`; 952 Android tests); the key is the owner's `openssl rand -hex 32`, only in `local.properties` and NPM; rotation by a second `if` line, a new APK through FR-16, then the old line removed. Accepted: landing page and browser use only on the LAN, outside health check through the app's About screen or `curl -H`, a leaked APK leaks the key until rotation, no flood protection. The gate rehearsed in NPM v2.16.0's openresty with its includes (AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1). Without a decision id: `infra/README.md` step 4a (key, APK, gate, acceptance test), its `CARGO_BUILD_JOBS` text (in effect since `b4d9467`; run 69: `kvitt-server` 2 h 42 min) and its owner decisions marked answered. |
|
||||
| 0.26 | 2026-10-21 | Pull-to-refresh on Home and account detail (D-127, owner request 2026-10-21; Android `3778f5f`; revises design-spec S-4 "Pull-to-refresh is not offered"): pulling S-4 or S-5 past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu"; the indicator stays while the sync runs and goes when it returns, success or failure; a failure shows only in the sync pill (S-5 has none, so a failed pull there shows nothing until Home's pill); offline the pull ends at once without a sync; a pull while a sync runs starts no second one. Shared `KvittPullToRefresh` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator (48 dp `primary-container` disc, the `sync` icon 18 dp turning 270° with the pull and popping from 80 % at the threshold on the new `KvittMotion.spatialFast()` with a `GestureThresholdActivate` haptic, a 32 dp `primary` arc that fills to the threshold and turns indeterminate while refreshing); `ContainedLoadingIndicator` replaces the arc once the pinned Material 3 (1.4.0) ships it. TalkBack reads "Synkar…" (`sync_syncing`, reused) as a polite live region; S-16 stays the TalkBack path. New goldens `S-4-home-refreshing` and `S-5-account-refreshing`; 959 Android tests. No change to `:core:model`, Room, the sync protocol, the server or `docs/ux/strings.csv` (AC-15.3, §9.9, §13.3; design-spec §2.11, §3.1, §3.6, S-4, S-5). |
|
||||
|
||||
Reference in New Issue
Block a user