Charge Detail & Contest
Routes /charges/:chargeId and nested /charges/:chargeId/contest plus /charges/:chargeId/photo/:photoIndex. This is the core lifecycle surface — every bonus except statement converges here.
Charge detail (/charges/:chargeId)
KxHeroAppBar + Back
Intro — "This charge will be automatically accepted on <deadlineAt localized>" (uses ServerAdjustedClock)
Fields (KxReadonlyField) — Item, Type, Notes (read-only), Amount (minor→currency format), Location, RaisedAt/DeadlineAt
Photo strip (KxPhotoStrip) — 0..n ChargePhoto { url, thumbnailUrl, altKey } horizontally scrollable; thumb tap → /photo/:i ; web click+wheel + native pinch (KxImage interactive viewer)
Actions —
outstanding: [Contest (outlined red) | Accept (filled navy)]
accepted: [Pay]
contested/resolved/paid: none (status banner explains)
— Actions rendered via KxButton with 48 px target + focus ring
Accept flow: ChargeDetailCubit.accept(chargeId) → ChargePorts.accept(expectedEpoch, expectedVersion) → Outbox + optimistic accepted → success SnackBar + History entry.
Contest (/charges/:chargeId/contest)
Title + Back
Instruction copy (localized via ARB)
Reason (multiline TextField, wraps, edit affordance)
validation: package:characters graphemeCount ∈ [10, 2000]
submit disabled until >=10, enabled at exactly 10 (boundary test)
inline error carries rule code, not sentence
Attachments (optional 0..5)
pickers: FilePicker (platform), MIME whitelist same as backend (jpeg/png/webp/mp4/pdf)
per-file ≤10 MiB, total ≤25 MiB enforced client-side before API call
list shows sanitized displayName + size, SHA-256 preview not shown to user
Submit -> ChargePorts.contest(metadata + blobs) -> Outbox enqueue
optimistic contested, banner warning, reason persisted in History
Draft persistence: contest_draft.dart debounces Reason (300 ms) and writes ContestDrafts(chargeId, reason, updatedAt); reload restores.
Validation parity: contest_policy.dart uses Characters(reason).length (UAX #29 via characters) mirroring app/core/graphemes.py. Vectors replay at test/features/charge/application/contest_policy_test.dart — flag emoji 🇬🇧, ZWJ family, combining accent, CR LF each counted as one.
Photo viewer
Route /charges/:chargeId/photo/:photoIndex renders full KxImage with InteractiveViewer (pinch zoom native, scroll-zoom web). Invalid index repair (-1, NaN) redirects to parent charge with ?notice=photoIndexRepaired and a SnackBar.
Concurrency
charge_concurrency.dart snapshots meta.stateEpoch + charge.version at tap time. On 409 store.epoch_mismatch or charge.version_conflict, the next attempt fetches fresh snap; Idempotency-Key: uuid guarantees safe replay.
History & statement links
- Contested reason survives reload and shows latest value inside
Historycard. - From contested → resolver outcome (
accepted|resolved) arrives viaLiveSyncServiceor snapshot refresh; no polling.
Analytics
Each view/transition fires analytics_store entry (mock.charge.view, mock.charge.accept, mock.charge.contest_submit) via ChargeDetailCubit and ContestCubit observers.
Tests
test/features/charge/application/contest_policy_test.dart— 10- vs 11-grapheme boundary + flag/emoji families.test/widget/contest_page_test.dart— disabled/enabled submit matrix, wrap, focus.test/features/charge/presentation/charge_detail_golden_test.dart— light/dark banner variants.- Integration
integration_test/app_test.dart: contest— fixture boot → hub contested card → reason persists aftertester.restartAndRestore().