diff --git a/PLAN-M8.md b/PLAN-M8.md index a46cab7..b315e10 100644 --- a/PLAN-M8.md +++ b/PLAN-M8.md @@ -18,7 +18,7 @@ Assemble the app: `:app` navigation and deep links, Hilt bindings from the `:cor - Feature convention (D-017): each feature exposes one composable entry point per screen with a `hiltViewModel()`-provided ViewModel and navigation as plain lambdas; `:app` owns the `NavHost` and connects every lambda to a route; fakes are bound only in test and debug-preview source sets. - Strings (D-017): `:app` holds only the app name; the notification copy and the channel name come from `:core:design`'s shared strings. - `google-services.json` is in `infra/secrets/` of the main checkout (M2c-dev, D-022); M8 copies it into `android/app/` (git-ignored) with `infra/dev/copy-google-services.sh`. -- Navigation: bottom navigation Home (Översikt), Analytics (Statistik), Settings (Inställningar); FAB "Add expense" on Home and Account detail; account detail, editors, settle up, details and settings sub-screens are pushed screens with back; the expense editor is full screen (§13.2). +- Navigation: bottom navigation Home (Översikt), Analytics (Statistik), Settings (Inställningar); FAB "Add expense" on Home and Account detail; account detail, editors, settle up, details and settings sub-screens are pushed screens with back; the expense editor is full screen (§13.2). Since SPEC v0.11 (D-071): the top-level screens S-3, S-4, S-11 and S-13 draw their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`, so the `:app` scaffold draws no second bar; argument keys are constants in each feature (`ARG_EXPENSE_ID`/`ARG_ACCOUNT_ID`, `SettleArgs.SETTLEMENT_ID`/`ACCOUNT_ID`, `DrillDownArgs.KEYS`, settings `"categoryId"`/`"templateId"`), read through `SavedStateHandle`; result snackbars of S-7, S-9 and account archiving are shown by the screen returned to (Home's `snackbarHostState`). - Deep links: `kvitt://invite?server=…&code=…` and `https://icehack.asuscomm.com:8443/i/` (unverified link) open S-1 with both fields filled (AC-2.6). Only `MainActivity` and the FCM service are exported (§8.8). - After pairing: full sync, then the `POST_NOTIFICATIONS` request (AC-2.9); partner pairing goes through S-2 before Home (AC-2.10). - Push handling: enqueue an expedited sync and wait up to 20 s; build the notification from local data in the phone's language per the §13.3 copy, or the generic text when the sync failed or the entity is missing; channel id `activity`, name "Activity"/"Aktivitet", importance default; notification id = hash of `entity_id`, fixed id for `sync_batch`; tap opens S-8 or S-10, Home for deletions and `sync_batch`; unknown `type` or `v` ≠ `1`: sync only (§14.6, AC-13.5, AC-13.6). @@ -29,8 +29,8 @@ Assemble the app: `:app` navigation and deep links, Hilt bindings from the `:cor - Version properties `kvitt.versionCode`, `kvitt.versionName` (debug `1` and `dev`); release signing from `android/local.properties` keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias`, `kvitt.release.keyPassword` (§10.6). - During development the debug build pairs against the server on the Mac over the home Wi-Fi (D-001); development data on that server is disposable. - Application ids `se.hagstedt.kvitt.debug` (debug), `se.hagstedt.kvitt` (release) (CLAUDE.md). -- M5's contract requests, landed with SPEC v0.5 (§11.3 note M8, D-041): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` to `:data` and includes `DataBindingsModule` (`@DisableInstallInCheck`) in the release graph, while debug binds the fakes; WorkManager is configured with `KvittWorkerFactory` and the default `WorkManagerInitializer` disabled in the manifest; `:app` calls `SyncTriggers.onAppStart`, `SyncTriggers.onForeground` and `SyncTriggers.syncForPush` (push handling, 20 s wait), and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` provided by `:data` is set as Coil's singleton. -- Open follow-up (D-027): on the first sync attempt after a `clock_skew` rejection the phone re-stamps `updated_at` of each such pending row to the current clock before pushing. Not in `:data` at `c28c52b`; the orchestrator assigns it to M8 (as a contract request on `:data`) or to a small `:data` agent. +- M5's contract requests, landed with SPEC v0.5 (§11.3 note M8, D-041): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` to `:data` and includes `DataBindingsModule` (`@DisableInstallInCheck`) in the release graph (done pre-M8 in `e6ad858`, with `AppBuildInfo` from `BuildConfig`); debug binds the fakes through the temporary `android/app/src/debug/.../DebugFakesModule.kt` (`72f21b2`), which M8 deletes so both variants bind `:data`; WorkManager is configured with `KvittWorkerFactory` and the default `WorkManagerInitializer` disabled in the manifest; `:app` calls `SyncTriggers.onAppStart`, `SyncTriggers.onForeground` and `SyncTriggers.syncForPush` (push handling, 20 s wait), and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` provided by `:data` is set as Coil's singleton. +- Open follow-up (D-027): on the first sync attempt after a `clock_skew` rejection the phone re-stamps `updated_at` of each such pending row to the current clock before pushing. Still not in `:data` at `3a1bd14`; since SPEC v0.11 it is on the M12e contract-pass list (§11.3), not M8's. ## 4. Owned paths From SPEC §14.7, verbatim: `android/adapter/fcm/**`, `android/app/**` (after the M2b shell) (owner M8; after landing: "later integration plans"). Catalog entries and module includes may be added (§14.7, `android/gradle/**` row). diff --git a/SPEC.md b/SPEC.md index 539b7be..f11de40 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.10** (2026-10-18). Schema version **2**. Sync protocol version **1**. Push payload version **1**. +Version **0.11** (2026-10-18). Schema version **2**. 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. @@ -213,7 +213,7 @@ The server starts with an empty database. The household is created from the comm * AC-9.5 A generated expense that a user deletes is not recreated. * AC-9.6 Each generated expense writes an audit entry with `source` = `recurring` and sends `recurring_expense_created` to every device of both users. * AC-9.7 The template list shows each template's description, amount, account, day of month, paused state and next occurrence date (computed by the server, `next_occurrence_date`). -* AC-9.8 The editor's default `start_period` is the current month if `day_of_month` is today or later in the phone's time zone, else the next month. The user may choose a `start_period` up to 12 months in the past (backfill) and any month in the future. +* AC-9.8 The editor's default `start_period` is the current month if `day_of_month` is today or later in the phone's time zone, else the next month. A new template's `day_of_month` defaults to today's day of the month, so the default `start_period` is the current month until the user picks an earlier day (D-072). The user may choose a `start_period` up to 12 months in the past (backfill) and any month in the future. ### FR-10 Balances @@ -230,7 +230,7 @@ The server starts with an empty database. The household is created from the comm * AC-11.3 Global settle-up (from Home): the direction defaults to "the user who owes pays", the amount defaults to the absolute global balance (or empty when it is zero). The app computes the proposal with `propose_allocation` (§9.4, §14.5) and shows one editable row per proposed account plus "Add account" to add any other account (active or archived). The confirm button is enabled only when the allocations sum to the amount; the remaining difference is shown live. * AC-11.4 Per-account settle-up (from the account screen) creates a settlement with exactly one allocation for that account; the amount defaults to the absolute account balance and the direction to "the user who owes in this account pays". * AC-11.5 An allocation larger than the payer's current debt in that account is allowed (over-settling flips the account's sign) and is marked with a warning per row ("This settles more than is owed in "). The confirm dialog repeats the warning. -* AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances. +* AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances. An edited settlement opens with its saved allocation rows counted as edited by the user, so changing the amount keeps them; "Suggest again" (`settle_propose_again`, "Föreslå igen") re-proposes (D-074). * AC-11.7 A settlement row in lists shows " paid X" and the account count when more than one allocation exists. In an account's list (S-5) X is the settlement's allocation to that account, not the settlement total; everywhere else X is the total. * AC-11.8 The server's `POST /settlements/proposal` returns exactly what the phone's `propose_allocation` returns for the same server state (verified by shared vectors). The phone always computes locally; the endpoint exists for parity tests and server-side tooling. @@ -282,6 +282,7 @@ The server starts with an empty database. The household is created from the comm * AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand: `UpdateRepository` emits `UpdateState.NeedsInstallPermission` once (the explanation-shown flag in `prefs.preferences_pb`, §14.2.2), and the next `install()` proceeds to the system prompt. * AC-16.4 A verification failure deletes the file and shows "Download failed, try again". * AC-16.5 Debug builds never check for updates. +* AC-16.6 "Check for update" on S-22 calls `UpdateRepository.check()`; when `UpdateState` is still `None` afterwards it reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"). A network failure cannot be told apart because `check()` returns nothing; accepted for v1, with a contract request in §11.3 (D-073). ### FR-17 Splitwise import @@ -431,7 +432,9 @@ Module graph (directory `android/`): | `:feature:settings` | Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | `:core:model`, `:core:design` | | `:app` | Android application | `MainActivity`, navigation graph, Hilt wiring, deep links, notification channel and building, `Application` class | everything | -**Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose. Golden layout (D-066, M6): one golden per component sheet (or screen state) × light/dark × sv/en, named `__.png`, plus a `_200` variant at 200 % font scale for the long states; the content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Feature modules (M7a to M7f) follow the same layout. +**Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose. Golden layout (D-066, M6): one golden per component sheet (or screen state) × light/dark × sv/en, named `__.png`, plus a `_200` variant at 200 % font scale for the long states; the content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Feature modules (M7a to M7f) follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen at 411 × 891 dp, xhdpi (as landed by M7a to M7f). + +**Hilt wiring before M8.** `:app` includes `DataBindingsModule` (D-041) in the release graph since `e6ad858` (pre-M8, with `AppBuildInfo` from `BuildConfig` in both variants). Until M8, debug builds bind every `:core:model` interface to `FakeRepositories(FakeScenarios.empty)` in `android/app/src/debug/.../DebugFakesModule.kt` (`72f21b2`, temporary); M8 deletes that file, so both variants bind `:data`. Fakes stay in test and preview source sets only. Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`. @@ -741,7 +744,7 @@ Strings are stored as validated, that is trimmed (§8.6, D-034). On the phone, a rule of this table that has no domain error token of §14.5.3 (the length limits, the date limits, `day_of_month`, the periods) is reported as `FieldError.error` = `validation_failed` (§14.2.4), the same token as the REST code; a missing required value is `required`. -The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point). +The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point). A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5") (D-078; `AmountInput.of` and `AmountInput.percent` in `:core:design`). ### 9.7 Icon and colour tokens @@ -778,13 +781,14 @@ The server validates icon and colour against these lists (`validation_failed`). * Dates in lists: Swedish `3 okt.`, English `3 Oct`; with year when not the current year (`3 okt. 2025`, `3 Oct 2025`). Detail views: Swedish `lördag 3 oktober 2026`, English `Saturday, 3 October 2026`. Timeline times: 24-hour `14:02` in both languages. * Relative "last synced": "just now" (< 1 minute), "N min ago" (< 60 minutes), "N h ago" (< 24 hours), otherwise the list date and time. Swedish: "nyss", "för N min sedan", "för N h sedan". * Chart axis labels (months): Swedish three-letter forms without a dot (`maj`, `jun`, … `okt`), English `May`, `Jun`, … `Oct`; list dates keep the Swedish abbreviation dot (`3 okt.`, `25 sep.`; `mars`, `maj`, `juni`, `juli` are written out) (D-065). +* Day ranges in captions and summaries (S-11 total caption, S-12 summary) are written out with the year: Swedish `24 augusti–17 oktober 2026`, `1–30 november 2026`, `28 december 2025–3 januari 2026` (the year once when both ends share it, the month once when both ends share it); the period chip and the S-12 title keep the short form `24 aug.–17 okt.` (D-077, extends D-065; `DateFormatter.dayRangeLong`). * File sizes (the update banner, S-17): SI megabytes (10^6 bytes) with one decimal rounded half up and U+00A0 before `MB`: Swedish `8,4 MB`, English `8.4 MB` (D-065). * Month and weekday names in Swedish and English are tables inside `DateFormatter`, not platform locale data, so JVM tests and the phone give identical output (D-065). * Formatting lives in `:core:design` (`MoneyFormatter`, `DateFormatter`), never in `:domain`. ### 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": create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update. 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. ### 9.10 Other edge cases @@ -1132,9 +1136,13 @@ Category contract v2 (D-054), after SPEC v0.8: M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b server (migration 0002, importer table; landed 2e2ebe7) └─► M12c android (:core:model, :domain, :data, fakes; landed e499107) ─► M6, M7c M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed bdba9d3) ─► M7a..M7f (ran in parallel with M6, landed 3080804) + +After the M7 wave (v0.11): +M7a..M7f (landed) ─► M6b core:design fixes (landed 3a1bd14) ─┬─► M8 app integration (started 2026-10-18) + └─► M7g feature cleanups (started 2026-10-18) ``` -After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `6b60ef7`, `2e2ebe7`, `e499107`). M12d (D-059) ran in parallel with M6 (landed `bdba9d3` and `3080804`); M7a to M7f started 2026-10-18, in parallel. +After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `6b60ef7`, `2e2ebe7`, `e499107`). M12d (D-059) ran in parallel with M6 (landed `bdba9d3` and `3080804`); M7a to M7f ran in parallel and landed on 2026-10-18 (`b9b061a`, `31760f9`, `1e42f76`, `082b8dc`, `6ac711d`, `a54eb7c`), followed by M6b, the `:core:design` fix pass (`3a1bd14`). M8 and M7g (feature cleanups) started 2026-10-18 in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`). ### 11.2 Milestone table @@ -1156,13 +1164,15 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | M12c | Category contract v2: Android | Landed `e499107` (D-055 to D-057). `:core:model`: `DefaultCategoryKey` (14), `ColorToken` (`SLATE`, `AMBER`), `CategoryIcons.ALL` (33), `ExpenseRepository.suggestCategory`; `:data`: the `UnicodeText` binding (`java.text.Normalizer`, `Character`), `suggestCategory` from Room, DataStore and the keyword table copied from `contracts/category-keywords.csv` at build time (D-055; a test asserts byte identity and the §14.8 rules), parsed by `:domain`'s `parseCategoryKeywords` (D-056); `:core:fakes`: fourteen defaults in the empty scenario and a fake `suggestCategory`; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live | Android gate | none | M12a, M2b, M5 | | M12d | UI contract additions (D-059) | Landed `bdba9d3` (D-060 to D-062). `:core:model`: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` (§14.2.4 "Changes in v0.9"); `:data`: both computed from Room, with repository tests; `:core:fakes`: both computed from each scenario's own data; `ContractTest` updated | Android gate | none | M12c, M5 | | M6 | Design system | Landed `3080804` (D-063 to D-066). Built from `docs/ux/design-spec.md` §2 and §3 and `docs/ux/strings.csv`. `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts (Compose `Canvas`, D-063), formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, M12c, the §9.7 colour change | -| M7a | Feature: onboarding | Started 2026-10-18. `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6, M12d | -| M7b | Feature: home and account | Started 2026-10-18. `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6, M12d | -| M7c | Feature: expense | Started 2026-10-18. `:feature:expense` screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) | as M7a | none | M6, M12c, M12d | -| M7d | Feature: settle | Started 2026-10-18. `:feature:settle` screens S-9, S-10 | as M7a | none | M6, M12d | -| M7e | Feature: analytics | Started 2026-10-18. `:feature:analytics` screens S-11, S-12 | as M7a | none | M6, M12d | -| M7f | Feature: settings | Started 2026-10-18. `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6, M12d | -| M8 | App integration | `:app` navigation and deep links, Hilt bindings from interfaces to `:data` and `:domain`, `:adapter:fcm`, notification building (AC-13.5), update banner wiring, revoke wipe, network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate; on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced | Tap through the pairing and the offline test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) | +| M6b | Design-system fixes after M7 | Landed `3a1bd14` (D-075 to D-079). `:core:design` fixes the M7 agents asked for: `viewerPercentOf` reads basis points, `editor_split_full_on_you`, `AmountInput.of` with both decimals and `AmountInput.percent` (D-078), `quickRowCategories` (D-075), two-line category labels with an ellipsis (D-076), `DateFormatter.monthName` and `dayRangeLong` (D-077), resource-backed snackbars in `KvittScaffold`, optional `ConfirmDialog` body, `KvittButton` loading form, `InviteCodeField` wrapping, `EmptyState` icon tile, `AnalyticsFilterBar` period row, screen tokens in `KvittDimens`, `common_ok` and `common_undo`, the module keys of M7b to M7f recorded in `docs/ux/strings.csv` (D-079) | Android gate | none | M7a to M7f | +| M7a | Feature: onboarding | Landed `b9b061a` 2026-10-18 (D-071). `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6, M12d | +| M7b | Feature: home and account | Landed `31760f9` 2026-10-18 (D-071). `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6, M12d | +| M7c | Feature: expense | Landed `1e42f76` 2026-10-18 (D-074, D-080). `:feature:expense` screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) | as M7a | none | M6, M12c, M12d | +| M7d | Feature: settle | Landed `082b8dc` 2026-10-18 (D-074). `:feature:settle` screens S-9, S-10 | as M7a | none | M6, M12d | +| M7e | Feature: analytics | Landed `6ac711d` 2026-10-18 (D-069 to D-071). `:feature:analytics` screens S-11, S-12 | as M7a | none | M6, M12d | +| M7f | Feature: settings | Landed `a54eb7c` 2026-10-18 (D-071 to D-073). `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6, M12d | +| M7g | Feature cleanups | Started 2026-10-18. Cleanups in `android/feature/**` after M6b; scope in its brief | as M7a | none | M7a to M7f, M6b | +| M8 | App integration | Started 2026-10-18 (in flight; decisions D-085 to D-089 reserved, renumbered to D-081 onwards on landing). `:app` navigation and deep links (§13.2, D-071), deletion of the debug fakes module (§6.2), Hilt bindings from interfaces to `:data` and `:domain`, `:adapter:fcm`, notification building (AC-13.5), update banner wiring, revoke wipe, network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate; on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced | Tap through the pairing and the offline test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) | | M9 | Splitwise importer | Landed `ab298ab` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 | | M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6, outside the repository; install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi, M3, M4, M8 | | M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 | @@ -1173,10 +1183,22 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015). `server/rust-toolchain.toml` pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3). * **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes how `resolveRange` signals an invalid `CUSTOM` range (§14.5.4, D-018: `IncompleteCustomRangeException`, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities and `KvittDatabase` v1 in `:data` (D-012), so M5 builds on them. * **M5** owns `:data` and builds on the entities, `KvittDatabase` v1 and the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an `SQLiteConnection` (§14.2.3). -* **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes//` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). +* **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes//` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). All six landed on 2026-10-18 (`b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f); their readings are D-069 to D-074 and D-080, the shared fixes they asked for went into M6b (`3a1bd14`, D-075 to D-079). M7g (started 2026-10-18) does the feature-side cleanups after M6b. +* **M6b** (landed `3a1bd14`) is the `:core:design` fix pass after the M7 wave, in M6's paths (§14.7); see the §11.2 row. +* **Contract requests for a later contract pass (M12e)**, collected from the M5, M6 and M7 reports; none is a contract change in v0.11, each needs the §14.8 rule when taken: + * the `pair()` result lacks the invite kind, so S-2 shows for any first `member` pairing, device invites included (AC-2.4, AC-2.10); + * the `kvitt://invite` link carries no admin name, so `onboarding_code_help` ("You get the code from %1$s …") cannot be filled and S-1 shows the generic help line (AC-2.6); + * `SyncSummary` has no "last attempt failed" flag; `SyncStatusPill` is called with `failed = false` (§13.3 S-4); + * `Rejection` has no actor, so the stale-change copy on S-16 names the partner; + * `Device` has only `appVersionCode`, so S-20 rows show "Kvitt 11" where the design shows the version name "1.1"; + * `RecurringRepository.create` needs a `TemplateId` for a new template; the S-19 editor sends `TemplateId("")`; + * `UpdateRepository.check()` has no result, so S-22 cannot tell "up to date" from a network failure (AC-16.6, D-073); + * `SettleState` lacks the list of accounts the settlement leaves untouched; S-9 derives it from Home balances, which include the settlement being edited (D-074); + * the D-027 re-stamp (re-stamp `updated_at` of pending rows after a `clock_skew` rejection) is still not in `:data`; + * the 128 px picture size: `:data` always fetches E-11 at 512; a 128 variant needs a size parameter in `:core:model` and `:data` (§14.2.4 "Pictures"); the owner answers whether it is needed. * **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10. * **M4** (landed `bd6f10a`) added `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, `tests/sync_api/`, `tests/push_fcm/` and `tests/scheduler_daily/` (§14.7); the scheduler lives in `src/tasks/scheduler.rs`. -* **M8** is the only plan that edits `:app` after M2b. It also carries M6's contract requests (v0.10, see M6 above), the D-027 re-stamp, and the 128 px picture size question (§14.2.4 "Pictures"). M5's contract requests (v0.5): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` and includes `DataBindingsModule` in the release graph (D-041); WorkManager is configured with `KvittWorkerFactory` and the default initializer disabled; `:app` calls `SyncTriggers.onAppStart`, `onForeground` and `syncForPush`, and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` that `:data` provides is set as Coil's singleton. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7). +* **M8** is the only plan that edits `:app` after M2b. In v0.10 it was given M6's contract requests (see M6 above), the D-027 re-stamp and the 128 px picture size question (§14.2.4 "Pictures"); it keeps the `:app` ones (`LocalAvatarPictures`, `ProvideHousehold`, `LocalKvittClock`, the One UI font check). Since v0.11 the items that need `:core:model` or `:data` (the `SyncSummary` "last attempt failed" field, the D-027 re-stamp, the picture size) are on the M12e list below; M8 wires the navigation of §13.2 (D-071) and deletes the debug fakes module (§6.2). M5's contract requests (v0.5): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` and includes `DataBindingsModule` in the release graph (D-041); WorkManager is configured with `KvittWorkerFactory` and the default initializer disabled; `:app` calls `SyncTriggers.onAppStart`, `onForeground` and `syncForPush`, and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` that `:data` provides is set as Coil's singleton. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7). * **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 (D-024). * **M2c-pi**: the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint (D-023). * **M9** (landed `ab298ab`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`. @@ -1222,6 +1244,8 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs * A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail. * Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back. * The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room). +* Wiring (D-071, as built by M7a to M7f; M8 connects it): the top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar, and the `NavHost` switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`. +* Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to (Home shows them through its `snackbarHostState`); the account-archived snackbar has an undo action (`common_undo`, "Ångra"). ### 13.3 Screens and what each must show @@ -1229,28 +1253,28 @@ Each screen must be designed in these states where they apply: **loaded**, **emp | Id | Screen | Must show | Actions | |---|---|---|---| -| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); 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; progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code | -| S-2 | Partner profile step | Display name field (prefilled), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob") | Continue | -| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any | 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 (`SyncStatusPill` takes a `failed` flag for "Sync failed" until `SyncSummary` gets a "last attempt failed" field, an M8 contract request; no contract change in v0.10); update banner (S-17) when available; 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 ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), 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-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 | 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"); 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) | 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: