Skip to main content

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.

  • Contested reason survives reload and shows latest value inside History card.
  • From contested → resolver outcome (accepted|resolved) arrives via LiveSyncService or 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 after tester.restartAndRestore().