feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
# Flutter ↔ Backend API Contract Map
Written 2026-07-10 from a full read of both sides of the wire (every Flutter call
site in `lib/` and every backend route it touches). This is the context document
for `plans/next-enhancements.md` sections 6-7 (Flutter) and
`backend/plans/next-enhancements.md` section 9 (backend counterparts) — read it
before picking up any of those tasks. Gap IDs (**G1**-**G10**) below are referenced
from the task entries so a future session can trace a task back to the evidence.
## Endpoint inventory (what the app actually calls)
| # | Flutter call site | Method + path | Auth | Backend route |
|---|---|---|---|---|
| 1 | `auth_provider.dart` `login()` | `POST /api/v1/auth/login` | none (issues token) | `api/v1/auth/login/route.ts` |
| 2 | `auth_provider.dart` `checkLoginState()` | `GET /api/v1/auth/me` | Bearer | `api/v1/auth/me/route.ts` |
| 3 | `pending_documents_provider.dart` `_uploadAndProcess` | `POST /api/v1/documents/upload` (multipart: `image`, `latitude?`, `longitude?`, `scan_mode`) | Bearer, 401 enforced | `api/v1/documents/upload/route.ts` |
| 4 | `pending_documents_provider.dart` `_pollUntilParsed` (every 2s, ≤130×) and `documents_screen.dart` `_loadDocuments` | `GET /api/v1/documents` | Bearer, 401 enforced | `api/v1/documents/route.ts` |
| 5 | `editor_screen.dart` save, `product_editor_logic.dart` `_submit`, `pending_documents_provider.dart` `retrySync` | `PUT /api/v1/documents/:id` (`toPutPayload()`) | Bearer, 401 + per-store 403 | `api/v1/documents/[id]/route.ts` |
| 6 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `GET /api/skus` — via `apiBaseUrl.replaceAll('/api/v1', '/api/skus')` | **none** (classic dev route) | `api/skus/route.ts` |
| 7 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `POST /api/scan-pfm` (JSON `{image_base64}`) — same base-URL string hack | **none** (classic dev route) | `api/scan-pfm/route.ts` |
| 8 | `app_config.dart` `_isBackendReachable` (startup probe) | `POST /api/v1/auth/login` with `{}` | none | same as #1 |
Base URL: `AppConfig.apiBaseUrl` resolved once at startup (LAN first, ngrok
fallback), frozen into the singleton Dio client (`api_client.dart:13`). Timeouts:
`connectTimeout` 10s, `receiveTimeout` 240s (sized for upload's synchronous
210s-worst-case parse; every other call inherits it).
## Response envelopes
- **v1 success**: `{ status: "success", message?, data: ... }` — Flutter reads
`response.data['data']`.
- **v1 error**: `{ status: "error", error: { statusCode, code, message } }`
(`utils/api-error.ts` → `lib/http-status.ts`), mirrored by
`lib/core/network/api_exception.dart` (`ApiException.fromDioException`). These
two are deliberately kept in sync — keep it that way.
- **Classic routes**: ad-hoc shapes — `GET /api/skus` returns `{ skus: [...] }`,
`POST /api/scan-pfm` returns a flat
`{ classification, ocr, possibleMatches, layoutParsingResult }`. No envelope,
no auth, CORS-open.
## The DO document lifecycle (happy path, as implemented)
1. Capture → blur check → `addDocument()` persists a `PendingDocument` to Hive
(`uploading`) → multipart POST to `/documents/upload` with `scan_mode` (`'DO'`
or `'Product'` from `scanModeProvider`).
2. Upload route: dedups by `file_hash`+`kode_toko`; inserts `documents` row
(`parsed=false`); **synchronously** calls internal `/api/parse` (210s abort);
parse writes `metadata` JSONB + `ocr_items` rows and sets `parsed=true`.
Response `data` is a **stub** DocumentModel (real `id`, empty header/items).
3. Client flips item to `processing` and polls `GET /documents` every 2s, looking
for its `id` in the full list with `parsed==true` (the list endpoint filters
`WHERE parsed = true`, so "appears in list" *is* the parse-done signal).
4. On success the pending card routes to `/editor` (or `/product-editor` when
`scanMode == 'Product'`) with `pendingId` passed as `state.extra`.
5. Editor save: builds `DocumentModel` (`id = _document?.id ?? pendingId ?? now-ms`),
saves to Hive `documentBox`, `PUT /documents/:id`; on success removes the
pending item, on failure marks it `syncFailed` (retry = re-PUT via `retrySync`).
6. `documents_screen._loadDocuments`: shows Hive cache, then fetches the server
list, **clears the whole Hive box**, and re-saves only server rows.
The PUT route writes `metadata` in **both** shapes (legacy web keys `noPO`/
`customerInfo`/… *and* mobile `header`/`shipment` sub-objects); the GET list maps
whichever exists. `DocumentModel.fromJson`/`toPutPayload` match this contract.
## Gaps found (G1-G10)
**G1 — No `GET /api/v1/documents/:id`, and no parse status in the contract.**
`api/v1/documents/[id]/route.ts` only has PUT. The poller must fetch the *entire*
list every 2s (server side: one `documents` query + one `ocr_items` query **per
document per poll** — N+1 that grows with history size). Because the list filters
`parsed=true`, the client cannot distinguish "still parsing" / "parse failed" /
"not mine": a server-side parse failure (upload route swallows it,
`upload/route.ts:134`) surfaces only as the client's generic 260s timeout
("Gagal mengekstrak data (Timeout)"). Match detection also relies on a Dart object
*identity* trick (`found != doc`, `pending_documents_provider.dart:133`).
**G2 — Product flow calls unauthenticated dev routes via URL string-hacking.**
`product_editor_logic.dart:57,69` rewrites the base URL with
`.replaceAll('/api/v1', '/api/skus' | '/api/scan-pfm')`. Those classic routes are
documented (backend/CLAUDE.md) as dev-only, never-authed, and not part of the
production surface; backend task 4.5 (shipped) restricts the public ngrok tunnel
to `/api/v1/*`, so both calls are expected to fail off-LAN. The existing v1
alternative `GET /api/v1/master/skus` is **admin-only** (403 for store accounts)
and uses a different envelope (`{status,data}` vs `{skus}`), so the client can't
just switch paths.
**G3 — RESOLVED 2026-07-10 (root task 7.2 / backend task 11.1).** Double
classification per product scan. Upload with `scan_mode=Product` already ran
the classifier inside `/api/parse` and stored only the top-1 SKU as the
document's single item. The product editor then re-read the image file,
base64-encoded it (~MBs through Dio JSON), and re-ran the whole classify+OCR
pipeline via `/api/scan-pfm` — ignoring the stored parse result except as a
lat/lng fallback. Two GPU passes per photo; the reviewed result could
disagree with the stored one. Fixed by having `/api/parse`'s Product branch
call the same shared `classifyAndMatchProduct()` used by
`POST /api/v1/scan-product` (task 9.3) and persist the full result (top-5
`possibleMatches` + OCR `extractedExpiryDate`) in
`documents.metadata.productScan`, surfaced by `document-mapper.ts`. The
editor now reads this directly from the document instead of re-classifying —
one GPU pass per photo, editor opens instantly like DO Scan's does.
**G4 — Product documents are typed by magic strings, `scan_mode` is never
persisted.** The backend fabricates placeholder metadata for product scans
(`PO-PRODUCT-001`, `noSO 1002003004`, `DO-PRODUCT-999`, `plat B 1234 PFM`,
`order_untuk: "PRODUCT SCAN"` — `parse/route.ts:107-135`) and the Flutter side
detects "is a product doc" by `orderUntuk == 'PRODUCT SCAN'`
(`documents_screen.dart:110`, `product_editor_logic.dart:36`). `scan_mode` is
sent at upload and forwarded to parse but never stored in `documents` nor
returned by GET, and `DocumentModel` has no doc-type field. Editing `orderUntuk`
silently moves a doc between tabs.
**G5 — PUT to a client-generated ID can never succeed.** Both editors fall back
to `finalDoc.id = widget.pendingId ?? now-ms` when `_document` is null (e.g.
`pendingId` no longer found in the provider — the `orElse` stub at
`product_editor_logic.dart:26` makes this reachable). The PUT then targets
`/documents/<13-digit ms timestamp>`; the backend `parseInt`s it into a value
that can't match (or even fit) the int4 `documents.id` → 404/500 → the item is
stuck in `syncFailed` and every retry re-fails identically.
**G6 — Server refresh wipes locally-saved-but-unsynced documents.** After a
failed PUT the editors keep the corrected doc in Hive (`saveDocument(finalDoc)`)
and mark the pending item `syncFailed` — but `documents_screen._loadDocuments`
(`documents_screen.dart:55`) does `documentBox.clear()` and refills from the
server list, deleting the local-only copy from history. Recovery survives only
via the pending item's embedded `document`; the history list lies in between.
**G7 — Mock data presented as real data in the product editor.** Offline/error
fallback fabricates three hardcoded SKU "matches" with fake confidences
(`product_editor_logic.dart:116-126`) and fake batch/expiry dates
(`'15/12/2026','20/04/2027'` — also used whenever OCR extracted no expiry date,
line 103). A reviewer cannot tell mock from model output. AGENTS.md §5 wants mock
data behind an explicit Demo/Live switch, not silently inlined in the live path.
**G8 — Route params ride on `state.extra`.** `/editor` and `/product-editor`
receive `pendingId` via `GoRoute state.extra` (`app_router.dart:34,41`), which
does not survive process death/state restoration and can't be deep-linked; a
restored editor gets `pendingId == null` and renders empty (feeding G5).
**G9 — `checkLoginState` error handling is string-typed and fail-open.**
`auth_provider.dart:30` detects 401 by `e.toString().contains('401')` instead of
`ApiException.from(e).statusCode`, and any *other* failure (timeout, 500, dead
tunnel) silently keeps the user "logged in" with a possibly-stale profile in
SharedPreferences. Plan task 1.1's original claim ("never pings the server") is
stale — `/auth/me` *is* called now; the residual gap is this fragile detection.
**G10 — Dedup response is a second, emptier stub.** A duplicate upload returns
the *original* document's id with empty header/items and no `parsed` flag
(`upload/route.ts:75-96`); the client treats it as fresh and re-polls. Works if
the original parsed; if the original's parse failed (G1), the second client also
burns the full 260s timeout. Low severity on its own — folds into G1's fix.
**G11 — No draft/confirmed distinction — `parsed` triggers list visibility, not
user confirmation.** `GET /api/v1/documents` filters `WHERE parsed = true`. `parsed`
is set the instant the backend's OCR pass finishes (`api/parse/route.ts`), which
happens **synchronously right after upload** — before the mobile user ever taps
"Simpan & Konfirmasi" in the editor (that action only happens on
`PUT /api/v1/documents/:id`). A scan the user captured, previewed, and then backed
out of (without confirming) is already sitting in the server's document list with
blank/placeholder fields — visible both to the store account and to admin. Fix:
separate "OCR finished" (`parsed`) from "user confirmed" (`confirmed`) as two
distinct booleans, gate list visibility on the latter. Root cause of the
`document_card.dart:25` fallback to the placeholder string "Staff Toko" (empty
`namaPenerima` on an unconfirmed DO document). Task: backend §10.1 (adds column +
gates list) → Flutter §8.2 (adds `confirmed` field to `DocumentModel`). Added
2026-07-10 from user APK testing feedback.
**G12 — Product Scan documents carry fabricated PO/SO/DO placeholder values with
no real-world referent.** `api/parse/route.ts` Product-scan branch (lines ~107-118)
stamps every Product Scan upload with `noPO: "PO-PRODUCT-001"`, `noSO:
"1002003004"`, `noDO: "DO-PRODUCT-999"` — concepts that don't apply to a product
verification scan. Not user-facing: `pdf_service.dart`'s Product receipt branch
never prints them (shows "Nomor Batch"/expiry instead), and
`product_editor_submit_logic.dart`'s `_submit()` never reads them (it builds
`noPo`/`noSo`/`noDo` itself from the user's PO-link dropdown/batch selection). Only
visible in raw debug logs. Same class of issue as the already-fixed G7 (fabricated
SKU matches/dates) — dishonest raw data, low risk to remove since nothing meaningful
depends on the values. Task: backend §10.2 (bundled with §10.1, same file). Added
2026-07-10 from user APK testing feedback.
## Ownership
- Flutter-side fixes: root `plans/next-enhancements.md` §6 (G1 client half, G5,
G6) and §7 (G2 client half, G3, G4 client half, G7) and §8 (G11 client half).
G8/G9 are folded into existing §1/§4-adjacent tasks as noted there.
- Backend counterparts: `backend/plans/next-enhancements.md` §9 (G1/G10 server
half: GET-by-id + parse status; G2: non-admin v1 SKU read + v1 scan endpoint;
G4 server half: persist and return `scan_mode`) and §10 (G11 server half:
`confirmed` column + list filter + PUT flip; G12: remove fabricated PO/SO/DO).
- Cross-cutting sequencing: backend §9 ships first; Flutter §6/§7 consume it.
Backend §10 ships first; Flutter §8.2 consumes it. Flutter §8.1 is independent.
+137
View File
@@ -52,6 +52,97 @@ workflow and have no task numbers; see `git log` for real dates/history.
*(New features shipped via `n`/`next` go below, organized the same way, with task numbers.)*
## Flutter — Auth & Splash
- **1.3** Added a confirmation dialog before logout when the pending documents queue has unsynced items (`CameraDrawer._handleLogout()`, `lib/features/camera/camera_drawer.dart`). Previously, tapping Logout in the drawer called `AuthNotifier.logout()` unconditionally, which clears the local Hive cache and the in-memory pending queue (`localStorage.clearAll()` + `pendingDocumentsProvider.notifier.clearQueue()`) — any document still uploading, awaiting review, or stuck in `syncFailed` was silently destroyed. Now, if the queue is non-empty, a dialog ("Ada Dokumen Belum Tersinkron", with the pending count) requires an explicit "Ya, Logout" confirmation before proceeding; an empty queue still logs out immediately with no extra tap. Covered by `test/camera_drawer_logout_test.dart` (3 cases: no pending items, cancel with pending items, confirm with pending items) — shipped 2026-07-10.
## Flutter — Product Scan Review Flow
- **7.2** Eliminated the duplicate GPU classification pass (gap G3), sourced
from user feedback ("kenapa harus dilakukan dua kali... GPU tidak 2x
kerja") after they noticed Product Scan's review screen took visibly
longer to open than DO Scan's. Root cause: the backend already classified
the photo once at upload time, but the editor's
`_fetchClassificationAndSkus()` re-uploaded the same image and re-ran the
*entire* classify+OCR pipeline a second time via `POST /scan-product`,
purely to get the top-5 candidates and OCR-extracted expiry date the first
pass could have produced too. Fixed on the backend (see
`backend/docs/feature-list.md`'s 11.1 entry) by having the upload-time
parse persist that full result; the editor now reads
`DocumentModel.productScanMatches`/`productScanExtractedExpiryDate`
directly from the already-loaded document — synchronous, no network call
— mirroring exactly how `EditorScreen._loadDocumentData()` reads a DO
document's data with no request at all. Keeps one lightweight fallback
(`GET /master/skus`, a plain DB read with no GPU involved) for the rare
case where a document has zero stored matches. `_loading` no longer
starts `true`, so there's no spinner on the happy path. Tests:
`test/document_product_scan_field_test.dart` (3 cases for the new model
fields) and `test/product_editor_no_double_classify_test.dart` (proves a
document with stored matches renders immediately with no network call).
Verified live against the real backend: a genuinely fresh upload took 9s
(one real GPU pass) and the immediate `GET /documents/:id` already
contained 5 real candidate matches and the extracted expiry date, before
any editor interaction. Full suite 63/63 pass, `flutter analyze` clean —
shipped 2026-07-10.
- **Ad-hoc (Simplify Product Scan form)** — Shipped 2026-07-10. Removed the
"Hubungkan ke PO Dokumen" field from `ProductDropdownCard` per user request
— the Product Scan review form is now exactly 3 fields (SKU, Pilih Batch,
Catatan). Below the SKU dropdown, the matched product's name is now shown
as plain informational text (not a form field), mirroring
`ProductExpiryCard`'s existing "Tanggal terdeteksi otomatis dari OCR" info
row below the batch dropdown. Removed the now-unused
`_selectedRelatedPo`/`_doDocs`/`_loadDoDocs()` state from
`product_editor_data_logic.dart` (was Hive-reading local DO documents to
populate the removed dropdown); `product_editor_submit_logic.dart` no
longer sets `noPo` from a manual PO link (now always `''` for Product Scan
documents — no PO-link concept remains for this doc type).
`DocumentCard`'s Product Scan subtitle no longer appends `• {noPo}` (now
just shows the expiry date). Covered by `test/product_dropdown_card_test.dart`
(updated + 1 new case verifying the PO field is gone and the product name
info row is shown).
- **7.3 (G7 half)** Replaced silently-fabricated data in the Product Scan review flow (`ProductEditorScreen`) with honest fallback states. Previously: (1) any total classification-fetch failure populated three hardcoded fake SKU matches with fake confidence scores, indistinguishable from a real AI result; (2) whenever OCR found no expiry date, two fabricated future dates were offered as if they were real batches; (3) `ProductExpiryCard` displayed a literal hardcoded "92.4%" OCR Confidence Score unconditionally — there is no real confidence signal for expiry OCR anywhere in the pipeline, so this number was always fake. Now: a genuinely unrecoverable failure (the SKU master list itself can't load) shows an explicit "Gagal Memuat Klasifikasi Produk" retry screen; a classification-only failure degrades gracefully to manual SKU selection from the real master list, with an honest "Tidak ada rekomendasi otomatis" notice instead of a fake confidence bar; a missing expiry date forces manual entry instead of offering fake batches; and the expiry card now shows an honest "terdeteksi otomatis" / "diinput manual" source label instead of a fabricated percentage. Covered by `test/product_dropdown_card_test.dart`, `test/product_expiry_card_test.dart`, `test/product_editor_classification_failure_test.dart` — shipped 2026-07-10.
- **7.3 (G4 half)** Replaced the `orderUntuk == 'PRODUCT SCAN'` magic-string document-type check with a real `docType` field on `DocumentModel`, sourced from the backend's persisted `scan_mode` column (backend task 9.1, `document-mapper.ts`'s `docType`) once that shipped. Previously, editing a document's `orderUntuk` display text (a corrected/OCR'd field) could silently move it between the "DO Scan" and "Product Scan" tabs; now the tab/layout/receipt-format decision reads a dedicated field that isn't user-editable display text. Falls back to the legacy `orderUntuk` sentinel only for responses/cached data that predate the backend column. Updated call sites: `document_card.dart`, `documents_screen.dart`, `pdf_service.dart`, `product_editor_logic.dart`. Covered by `test/document_doctype_test.dart`, including a regression test reproducing the exact old bug trigger — shipped 2026-07-10.
- **7.1** Moved the product editor's classification/SKU-lookup calls off the classic, unauthenticated dev routes (`GET /api/skus`, `POST /api/scan-pfm`, reached via a `apiBaseUrl.replaceAll('/api/v1', ...)` string hack) onto the authenticated v1 surface (`GET /api/v1/master/skus`, `POST /api/v1/scan-product`) once backend tasks 9.2/9.3 shipped it — fixes product scanning being broken off-LAN (backend task 4.5 restricts the public tunnel to `/api/v1/*` only). New endpoint constants in `app_config.dart`; the auth token is attached automatically by `ApiClient`'s existing interceptor, same as every other v1 call. Also switched the classification request from a base64 JSON body to multipart (`FormData`/`MultipartFile`, reusing the pattern already proven for DO uploads) to avoid a multi-MB JSON payload, matching what backend 9.3 was built to prefer. Extracted the v1-envelope-unwrapping logic into a new pure file, `lib/features/editor/product_scan_response_parser.dart`, mirroring task 6.2's testable-pure-function pattern. Tests: `test/product_scan_response_parser_test.dart` (6 cases). Verified live against the real backend: a real store account succeeds against both new endpoints with the expected response shapes — shipped 2026-07-10.
## Flutter — API Contract & Sync Integrity (DO flow)
- **6.1** Replaced whole-list polling with per-document polling for pending
uploads. Previously, `_pollUntilParsed` fetched the *entire* `GET /documents`
list every 2 seconds and inferred "parse done" from the pending item's
document appearing in a `parsed=true`-filtered list via an object-identity
check — an `ocr_items` query per document per poll on the server, growing
with history, and no way to distinguish "still parsing" from "parse failed"
(a server-side parse failure only ever surfaced as a generic 260-second
timeout). Now polls `GET /api/v1/documents/:id` for just that item's own id
(backend task 9.1), reading a real `parseStatus` (`pending`/`done`/`failed`)
instead of guessing from list membership — a failed parse now ends the poll
immediately with an explicit error message instead of waiting out the full
timeout. The done/failed/pending decision lives in a new pure function,
`resolvePollOutcome()` (`lib/features/documents/poll_outcome.dart`), unit
tested without mocking Dio. Verified live against the running backend stack
— a real document's `GET /api/v1/documents/:id` response matches the shape
the new code expects. Covered by `test/poll_outcome_test.dart` (4 cases) —
shipped 2026-07-10.
- **6.2** Fixed a data-integrity bug where `DocumentsScreen`'s server refresh would silently overwrite a driver's corrected-but-unsynced document with the server's stale pre-edit copy (or drop it from the visible list) whenever its `PUT /documents/:id` save had failed and it sat in the pending queue as `syncFailed`. Added a pure, unit-tested merge function (`mergeDocumentsWithUnsyncedOverrides()`, `lib/features/documents/document_sync_merge.dart`) that lets a matching `syncFailed` pending item's corrected copy override the server's version (and survive even if the server list omits that document entirely). `DocumentCard` now shows a "Belum Tersinkron" badge (instead of a hardcoded "Terkonfirmasi") for any document currently overridden this way, so the driver can see at a glance which history entries still need a retry from the pending queue. Covered by `test/document_sync_merge_test.dart` and `test/document_card_unsynced_badge_test.dart` — shipped 2026-07-10.
- **6.3** Fixed a data-integrity bug where saving in either editor could PUT
to a fabricated client-side ID (a millisecond timestamp) instead of the
real server-assigned document id, whenever the editor opened without a
resolved server document (e.g. after a process-death restart lost the
`pendingId` route param) — the PUT would target a nonexistent id and the
item would loop in `syncFailed` forever with no way to recover. Now, when
no server document is resolved but the pending item's local image is still
available, the save flow auto-recovers by re-uploading that image (safe:
the backend dedups by `file_hash`) to get a real id, then PUTs the
corrected fields to it; only when neither a server document nor a local
image is available does it block with an explicit error instead of
guessing an id. The decision logic lives in a new pure function,
`resolveDocumentSaveAction()` (`lib/features/editor/document_save_action.dart`),
unit tested without mocking Dio. Verified live against the running
backend: re-uploaded a real test image to get a genuine server id, PUT
corrected fields to it, then confirmed via GET that the correction
persisted. Covered by `test/document_save_action_test.dart` (3 cases) —
shipped 2026-07-10.
## Flutter — Pending Documents Queue
- **3.1** Persisted the pending documents queue to a new Hive box (`lib/core/storage/local_storage.dart`) instead of holding it in memory only — an OS-level app kill mid-upload no longer silently loses the document. On next launch, `PendingDocumentsNotifier` reloads the queue and resumes anything left non-terminal: re-uploads a fresh capture from scratch (safe due to server-side file-hash dedup) or resumes polling for one whose upload already completed. Storage failures degrade gracefully to the old in-memory-only behavior rather than crashing the app — shipped 2026-07-08.
@@ -82,3 +173,49 @@ workflow and have no task numbers; see `git log` for real dates/history.
- Subtitle Details: `[Date] • [Item Count] Item` for DO cards, and `[Expiry Date] • [PO Number]` (without prefix labels) for Product cards.
- **Ad-hoc (Active Account Seeding)** — Shipped 2026-07-09
- Seeded 1 initial mock DO Scan document and 1 Product Scan document matching the currently logged-in account/store code if empty.
- **Ad-hoc (DO & Product Scan Workflow Realignment)** — Shipped 2026-07-09
- Synced default history view tab (`_selectedTab`) with the active scanner mode (`scanModeProvider`) on page load.
- Realigned category identifiers (`orderUntuk`) of newly captured product scan documents to `'PRODUCT SCAN'` so they appear under the correct tab immediately instead of switching tabs after edit/confirmation.
- Filtered the pending document queue display by tab category so pending DO documents and pending Product documents are kept separated.
- Implemented secure store-level data isolation by clearing the local Hive cache boxes on user logout, and delegating document list filtering directly to the backend API (`GET /api/v1/documents`) which filters Postgres results by the active account's `kode_toko`. This fixes the issue where DO documents were hidden due to string mismatches in client-side kepadaYth checks.
- Dynamic store profiling for newly captured mock product documents using `SharedPreferences`.
- Modularized `DocumentsScreen` (bringing it under 256 lines) by extracting widgets into helper components: `DocumentsTabSwitcher`, `DocumentsEmptyState`, and `DocumentsMockSeeder`.
## Flutter — Scan Mode UX & Confirmation Gate (§8)
- **8.1** Global scan-mode state + DO/Product color cue — shipped 2026-07-10
- `scanModeProvider` is now the **single source of truth** for the active tab.
`DocumentsScreen._selectedTab` removed; `build()` reads `ref.watch(scanModeProvider)`;
`DocumentsTabSwitcher.onTabChanged` writes `ref.read(scanModeProvider.notifier).state = tab`.
Eliminates the desync where changing mode in the camera drawer was not reflected in the
documents list tab (and vice-versa).
- `AppConfig.doModeColor` (`Color(0xFFF57C00)`) added as a shared constant —
formalizes the previously ad-hoc `Colors.orange.shade700` used only in `document_card.dart`.
- Color cue applied consistently across three call sites:
- `DocumentsTabSwitcher._buildTabItem`: active DO tab text → `doModeColor` (was `primaryColor`).
- `CameraDrawerModeToggle._buildSegment`: active DO segment → `doModeColor` (was `primaryColor`).
- `DocumentCard`: category label for DO → `doModeColor` (was inline `Colors.orange.shade700`).
- **Follow-up (same day)**: user clarified the icon, not just the text, should
carry the mode color — `DocumentsTabSwitcher` gained `Icons.description`/
`Icons.inventory_2` (matching `CameraDrawerModeToggle`'s existing vocabulary),
colored the same as the tab's text (`doModeColor`/`primaryColor` active,
`textSecondary` inactive). Deliberately scoped to the mode-toggle controls
only — generic/default icons elsewhere (search, print, back, tooltips) were
left untouched per explicit instruction.
- `CameraDrawer` split into three files to satisfy AGENTS.md §3 (256-line threshold on
touched files): `camera_drawer.dart` (232 lines, structure + logout),
`camera_drawer_mode_toggle.dart` (125 lines, DO/Product pill),
`camera_drawer_helpers.dart` (78 lines, section header, drawer item, dialogs).
- **8.2** Added an optional `confirmed` field to `DocumentModel` (default `true`,
same fallback pattern as `docType`/`parseStatus`) once backend task 10.1 shipped
`confirmed` on `GET /api/v1/documents`/`:id` — the client half of closing gap G11
(documents no longer showing in history before the user taps "Simpan &
Konfirmasi"; see backend `docs/feature-list.md`'s 10.1/10.2 entries for the
server-side fix). No other Flutter changes were needed: the existing
`mergeDocumentsWithUnsyncedOverrides()` (task 6.2) and the pending queue's
"Tertunda & Diproses" section already handled the new server behavior
correctly without modification — verified by re-reading both, not assumed.
Covered by `test/document_confirmed_field_test.dart` (3 cases) — shipped
2026-07-10.
- Tests: 7 new widget tests in `test/scan_mode_color_test.dart` covering all three color-cue
assertions and provider write-through. All 50 suite tests pass.
+867
View File
@@ -2,6 +2,700 @@
This log tracks code review audits and QA verifications performed upon completion of development iterations.
## Iteration: Task 7.2 — Single-Pass Product Classification, Closing Gap G3 (2026-07-10)
### Context
User feedback, two messages in sequence: first "kenapa ketika ingin klik
konfirmasi dokumen do scan itu langsung kebuka viewnya, sedangkan kalo buka
page konfirmasi dokumen scan produk itu ada loading lama dulu" (why does DO
Scan's confirm page open instantly while Product Scan's has a long loading
delay), then, after the root cause was explained, "kenapa harus dilakukan
dua kali... saya ingin sama seperti scan DO... GPU tidak 2x kerja" (why does
it have to happen twice — I want it like DO scan, GPU shouldn't run twice).
This is gap **G3** (`docs/api-contract-map.md`), previously left `[TODO]` in
root task 7.2 pending exactly this client-side decision between two options;
the user's second message resolved it in favor of "consume the stored parse
result" (single pass at upload, editor reads it) over "skip classification
at upload" — because DO Scan (the explicit reference point) does the former.
Used `EnterPlanMode` given the multi-file, cross-stack (backend + Flutter)
scope. Backend counterpart: `backend/docs/iteration-log.md`'s matching entry
for task 11.1.
### Completed Tasks
1. **Backend does one classify+match pass and persists the full result**
(`api/parse/route.ts`'s Product branch now calls the shared
`classifyAndMatchProduct()` instead of its own poorer inline fetch;
result stored under a new `metadata.productScan` JSONB key; surfaced by
`document-mapper.ts` as a top-level `productScan` field). Full detail in
the backend iteration log entry — this session's Flutter-side work
consumed that contract once it was live.
2. **`DocumentModel` gained `productScanMatches`/
`productScanExtractedExpiryDate`** (`lib/models/document_model.dart`),
parsed from the new `productScan` key, empty defaults for DO documents or
documents parsed before this fix.
3. **Rewired `product_editor_data_logic.dart`'s
`_fetchClassificationAndSkus()`** to read those two fields synchronously
from `_document` first — no network call at all when matches are present,
mirroring `EditorScreen._loadDocumentData()`'s instant local-state read
exactly. Only falls back to a `GET /master/skus` call (a plain DB read,
no GPU/classifier involved) when the document has zero stored matches —
deliberately kept, since the user's complaint was specifically about GPU
work happening twice, not about zero network calls ever. `_loading`'s
default flipped from `true` to `false` so there's no spinner on the happy
path; it's only set `true` transiently inside the fallback branch.
4. **Removed the now-dead `dart:io` import** from `product_editor_screen.dart`
(the `File(_imagePath)` existence check it supported no longer exists) —
caught by `flutter analyze`, not left dangling.
### TDD Process
- Wrote `test/document_product_scan_field_test.dart` first (3 cases: reads a
populated `productScan`, defaults to empty when absent, defaults to empty
when `possibleMatches` itself is missing) against not-yet-existing
`DocumentModel` getters — confirmed all 3 failed to compile, then
implemented until all 3 passed.
- Wrote `test/product_editor_no_double_classify_test.dart` to prove the core
claim of this fix, not just the model plumbing: seeded a raw pending-queue
JSON blob (mirroring `camera_drawer_logout_test.dart`'s pattern) whose
embedded document already carries a populated `productScan`, pumped
`ProductEditorScreen`, and asserted the matched product name and a real
confidence score render — with no assertion needed about network calls
directly, since if the old code path had run instead, the sandboxed test
`HttpClient`'s automatic 400 response would have driven the screen into
the failure/retry state instead, which the test explicitly asserts is
*not* shown.
- Re-ran the pre-existing `test/product_editor_classification_failure_test.dart`
unchanged and confirmed it still passes: a `pendingId: null` document has
no stored matches, so it now naturally exercises the *fallback* path
(rather than the old always-on classify path) — same sandboxed 400, same
resulting retry-state UI, still a valid regression test for a different
reason than before.
### Code Review & Audit
- **Reuse over reinvention**: the backend half reused the already-existing
`classifyAndMatchProduct()` (built for task 9.3's `/scan-product` route)
rather than duplicating richer classification logic a second time inside
`parse/route.ts` — a smaller, safer diff than it could have been.
- **Caught a real regression before it shipped**: delegating to
`classifyAndMatchProduct()` would have silently dropped the 90s pipeline
timeout the old inline fetch had. Fixed at the source (inside the shared
function itself) rather than working around it locally — benefits the
live `/scan-product` route too, which had the same latent gap.
- **Scope discipline**: left `POST /api/v1/scan-product` itself in place
even though nothing in this app calls it anymore post-fix — it's a
legitimate, independently-useful authenticated endpoint, and removing a
working route wasn't part of what was asked.
- **Compatibility**: a `PendingDocument` captured before this fix shipped
(already `success` status, sitting in the local queue across an app
update) has a `_document` with no `productScan` key — verified this
transparently falls into the same zero-match fallback path and still
works, just without a pre-filled AI suggestion for that one stale item.
### Verification Results
- `flutter test test/document_product_scan_field_test.dart
test/product_editor_no_double_classify_test.dart
test/product_editor_classification_failure_test.dart`: all pass.
- `flutter test` (full suite): 63/63 pass, no regressions.
- `flutter analyze lib test`: zero new issues (40 pre-existing info-level
lints, none in any file touched by this change).
- **Live backend verification**: uploaded a genuinely fresh image/store
combination (never uploaded before, to rule out a dedup hit) — took 9
seconds (one real GPU classify+match pass), and the immediate
`GET /documents/:id` response (no editor interaction) already contained 5
real `possibleMatches` with real SKU names/scores and the OCR-extracted
expiry date.
### Menu path to see the new feature
Camera screen → switch to "Product Scan" mode → capture a photo → tap the
pending card once it reaches "Ketuk untuk dikonfirmasi." The review screen
now opens immediately — same instant feel as DO Scan's confirmation
screen — instead of showing a loading spinner while the app re-runs the GPU
classifier a second time.
## Iteration: Task 8.2 — DocumentModel.confirmed, Closing §8 (2026-07-10)
### Context
Second and final task from the release-APK feedback plan
(`twinkly-riding-mitten.md`). Task 8.1 (global scan-mode state + color cue)
already shipped earlier the same day; this closes 8.2, the Flutter half of
gap **G11** (`docs/api-contract-map.md`) — documents appearing in history
before the user taps "Simpan & Konfirmasi". Backend §10.1/§10.2 shipped
first (this same session — see `backend/docs/iteration-log.md`'s matching
entry), adding a `confirmed` column, gating `GET /api/v1/documents` on it,
and removing Product Scan's fabricated PO/SO/DO placeholders.
### Completed Tasks
1. **Added `confirmed` to `DocumentModel`** (`lib/models/document_model.dart`):
optional `bool`, defaults to `true`, read from `json['confirmed']` — same
default-true fallback shape already used for `docType`/`parseStatus`, so a
legacy/cached response that predates the backend column behaves exactly
as before.
2. **Verified, rather than assumed, that no other Flutter change was
needed.** Re-read both consumers the plan flagged as likely-already-safe:
`document_sync_merge.dart`'s `mergeDocumentsWithUnsyncedOverrides()` (task
6.2) already keeps a `syncFailed` pending item's locally-corrected
document visible even when the server list omits it entirely — which it
now legitimately will for any unconfirmed document — so the merge logic
needed zero changes. `pending_documents_provider.dart`'s "Tertunda &
Diproses" section already renders in-flight items from local state
regardless of server confirm status.
3. **Icon follow-up to task 8.1** (same day, user clarified after 8.1
shipped): the mode-toggle's icon, not just its text/background, should
also carry the DO/Product color, while generic default icons elsewhere
(search, print, tooltips) stay untouched. Added `Icons.description`/
`Icons.inventory_2` to `DocumentsTabSwitcher` (mirroring
`CameraDrawerModeToggle`'s existing icon vocabulary), colored identically
to the tab's text.
### TDD Process
- Wrote `test/document_confirmed_field_test.dart` first (3 cases: reads a
real `confirmed: false` from JSON, defaults to `true` when the key is
absent, defaults to `true` via the plain constructor) against a
not-yet-existing `DocumentModel.confirmed` getter — confirmed all 3 failed
to compile (`isn't defined`), then implemented the field until all 3
passed on the first implementation.
- For the icon follow-up, extended the existing `test/scan_mode_color_test.dart`
(3 new cases: DO active icon color, Product active icon color, inactive icon
stays neutral gray) rather than writing a new file — found and removed two
test files (`documents_tab_switcher_test.dart`, `camera_drawer_mode_color_test.dart`)
that had been drafted independently before discovering `scan_mode_color_test.dart`
already covered the same widgets; consolidated into the existing file
instead of shipping duplicate coverage.
### Code Review & Audit
- **Concurrent-session reconciliation**: task 8.1's code, tests
(`test/scan_mode_color_test.dart`), and doc entries (root plan §8.1,
`docs/api-contract-map.md` G11/G12, backend plan §10 task descriptions)
were discovered already complete on disk from earlier the same session
before this iteration began — re-verified against the approved plan file
rather than blindly trusted, then built on top of instead of redone.
- **Non-breaking model change**: `confirmed` defaults to `true` in the
constructor, so no existing `DocumentModel(...)` call site across the app
or test suite needed updating.
- **Scope check**: did not touch G8 (`state.extra` routing) or any other
open gap; stayed to exactly what §8.2 and the icon follow-up specified.
### Verification Results
- `flutter test test/document_confirmed_field_test.dart`: 3/3 pass.
- `flutter test test/scan_mode_color_test.dart test/documents_screen_scan_mode_sync_test.dart test/camera_drawer_logout_test.dart`: 15/15 pass.
- `flutter test` (full suite): 58/58 pass, no regressions.
- `flutter analyze lib/models/document_model.dart lib/features/documents test`:
zero new issues (pre-existing `withOpacity`/`avoid_print` infos only).
- **Live backend verification** (see backend `docs/iteration-log.md` for the
server-side detail): confirmed the real `GET /api/v1/documents/:id`
response shape now includes `confirmed`, matching exactly what
`DocumentModel.fromJson` parses.
### Menu path to see the new feature
Camera screen → capture a photo → back out of the editor without tapping
"Simpan & Konfirmasi" (or simply don't open it yet) → Documents screen no
longer shows that scan in the dated history list below "Tertunda & Diproses"
(previously it would appear there immediately, once OCR finished, with
placeholder fields like "Staff Toko"). Confirming it in the editor is what
makes it appear. Separately, the DO Scan/Product Scan tab switcher and camera
drawer toggle now show a colored icon (orange for DO, green for Product)
alongside the colored text/button.
## Iteration: Never PUT to a Fabricated ID, Closing Task 6.3 (2026-07-10)
### Context
Continuing the 2026-07-10 API contract audit's root `plans/next-enhancements.md`
§6. Closes gap **G5** (`docs/api-contract-map.md`): both document editors could
build a `DocumentModel` with a client-generated millisecond-timestamp id and
PUT to it when no server-assigned document was resolved — a PUT that could
never succeed (the timestamp can't match the int4 `documents.id`), leaving
the item permanently stuck in `syncFailed`.
### Grill-Me Clarification
The task's own description named two open decisions, so both were resolved
with the user via `AskUserQuestion` before writing code:
1. **Recovery strategy** — auto re-upload the pending item's local image to
get a fresh server id (safe: server dedups by `file_hash`), then PUT the
corrections to it — chosen over surfacing an explicit blocked state with
no automatic recovery attempt.
2. **G8 scope** — explicitly *not* bundling the companion fix of moving
`pendingId` off `GoRoute`'s `state.extra` into route path/query in this
pass; kept as its own future task, consistent with how 6.2/7.1 stayed
narrowly scoped to their own gap.
### Completed Tasks
1. **New pure decision function** `resolveDocumentSaveAction()`
(`lib/features/editor/document_save_action.dart`, zero Flutter/network
imports — same pattern as task 6.1's `poll_outcome.dart`): given whether a
server document is already resolved and whether a local image path is
available, returns one of `putExisting(id)` / `reuploadThenPut()` /
`blocked(message)`.
2. **Rewired both editors' save flows** (`editor_screen.dart`'s
`_submitDocument()`, `product_editor_logic.dart`'s `_submit()`) to call
this function instead of directly falling back to
`DateTime.now().millisecondsSinceEpoch`. On `reuploadThenPut`, both now
run the same multipart-upload pattern already used for the original
capture (`FormData`/`MultipartFile`, `pending_documents_provider.dart`'s
`_uploadAndProcess`) to obtain a real id before proceeding to the existing
PUT logic unchanged. On `blocked`, an explicit snackbar is shown and the
save aborts instead of silently generating a doomed id.
3. Left `pending_documents_provider.dart`'s `retrySync`/`markSyncFailed`
untouched — since `finalDoc.id` is now guaranteed to be a real
server-assigned id by construction (the bug is fixed at the source), every
downstream consumer (Hive persistence, retry-PUT, the 6.2 sync-merge
logic) is automatically safe without any changes of its own.
### TDD Process
- Wrote `test/document_save_action_test.dart` first (3 cases: resolved
document -> `putExisting`; no document but a local image ->
`reuploadThenPut`; neither -> `blocked` with a non-empty message) against a
not-yet-existing `resolveDocumentSaveAction`/`DocumentSaveActionKind` —
confirmed all 3 failed to compile (`Method not found`), then implemented
`document_save_action.dart` until all 3 passed on the first
implementation.
### Code Review & Audit
- **Single Responsibility**: `document_save_action.dart` only classifies
which recovery path to take — it has no knowledge of Dio, multipart
encoding, or UI feedback; those stay in the two editor call sites.
- **Duplication**: the re-upload-then-PUT branch is duplicated (not
extracted into a shared helper) across the two editors, matching the
pre-existing pattern in this codebase where each editor already
independently builds its own `FormData`/PUT calls — introducing a
cross-cutting network-helper abstraction for two call sites was judged
premature versus the pure decision function, which is the part that
actually needed correctness coverage.
- **§3 file-size compliance (AGENTS.md)**: editing `editor_screen.dart` (381
lines) and `product_editor_logic.dart` (296 lines) put both over the
256-line threshold this rule enforces on any *touched* file, not just new
ones. Split both as part of this change: `editor_screen.dart` ->
widget-only `editor_screen.dart` (150 lines) + new `editor_logic.dart`
mixin (238 lines), mirroring the `part`/mixin pattern the product editor
already used. `product_editor_logic.dart` -> replaced by
`product_editor_data_logic.dart` (171 lines, loading/classification state)
and `product_editor_submit_logic.dart` (128 lines, `_submit()` only, `on
ProductEditorDataLogic`), split along the seam that already separated
those two concerns internally. All five resulting files are well under
the threshold; `flutter test` (43/43) and `flutter analyze` (zero new
issues) confirm the split didn't change behavior.
- **Scope check**: did not touch G8 (`state.extra` routing) per the
Grill-Me answer above.
### Verification Results
- `flutter test test/document_save_action_test.dart`: 3/3 pass.
- `flutter test` (full suite): 43/43 pass, no regressions.
- `flutter analyze lib/features/editor lib/models test`: zero new issues (19
pre-existing info-level lints, none in any file touched by this change).
- **Live backend verification**: with the Docker stack running, manually ran
the exact recovery sequence the new code performs — multipart-uploaded a
real test image (`backend/sources/test-images/do-001.jpg`) to
`/api/v1/documents/upload` (dedup hit, returned a real existing id `3388`),
PUT corrected header/shipment fields to that id, then GET'd the document
back and confirmed the corrections persisted (`namaDriver`/`namaPenerima`
matched what was PUT, `parseStatus: "done"`) — proving the recovery path is
a genuine save, not a dead end.
### Menu path to see the new feature
Not reachable via normal navigation on the happy path (the only entry point
into `/editor`/`/product-editor` already carries a valid `pendingId` with a
resolved document). Visible only in the recovery scenario this task targets:
if the editor is ever reached without a resolved server document but the
pending item's local image still exists, tapping Save now transparently
re-uploads and saves instead of silently failing forever; if no local image
exists either, Save now shows an explicit "Tidak dapat menyimpan..." message
instead of appearing to succeed while actually being unrecoverable.
## Iteration: Per-Document Polling, Closing Task 6.1 (2026-07-10)
### Context
Backend task 9.1 (`GET /api/v1/documents/:id` with `parseStatus`/`docType`)
shipped earlier in the 2026-07-10 session — verified directly against
`backend/pfm-web-app/src/app/api/v1/documents/[id]/route.ts` and
`document-mapper.ts` before starting, rather than assumed from the plan
entry's "blocked" note. This closes root task 6.1 (gap **G1**, `docs/
api-contract-map.md`), the first previously-blocked half of §6 to become
available.
### Completed Tasks
1. **Rewired `_pollUntilParsed`** (`pending_documents_provider.dart`) to call
`GET /api/v1/documents/:id` for the specific pending item's own id every
2s, instead of fetching the entire `GET /documents` list and searching it
via an object-identity trick (`found != doc`). Server-side this collapses
an N+1 (`documents` + `ocr_items` query per document per poll, scaling
with total history) down to a single row lookup per poll, independent of
history size.
2. **Added `parseStatus` to `DocumentModel`** (nullable, populated only by
the new per-id endpoint) and extracted the poll decision into a pure
function, `resolvePollOutcome()` (new file
`lib/features/documents/poll_outcome.dart`, zero Flutter/network
imports) — same pattern as task 6.2's `document_sync_merge.dart` and task
7.1's `product_scan_response_parser.dart`: `"done"` -> success with the
fetched doc, `"failed"` -> immediate error (no longer waits out the full
260s timeout to report a server-side parse failure), `"pending"`/absent
-> keep polling.
### TDD Process
- Wrote `test/poll_outcome_test.dart` first (4 cases: done, failed, pending,
and a legacy/null `parseStatus` treated as pending rather than a false
failure) against a not-yet-existing `resolvePollOutcome`/`PollOutcomeKind`
— confirmed all 4 failed to compile (`Method not found`), then implemented
`poll_outcome.dart` and the `DocumentModel.parseStatus` field until all 4
passed on the first implementation.
### Code Review & Audit
- **Single Responsibility**: `poll_outcome.dart` only knows how to classify a
`DocumentModel`'s `parseStatus` into an action — no Dio, no polling loop,
no timing logic. The loop/timeout/retry mechanics stay in
`_pollUntilParsed`.
- **Backward compatibility**: `parseStatus` defaults to `null` on
`DocumentModel`, and `resolvePollOutcome` treats `null`/unrecognized values
as `pending` rather than throwing or misreporting a failure — a
pre-9.1-shaped cached response can't cause a false "parse failed."
- **Scope check**: did not attempt 6.3 (never PUT to a client-generated ID)
in this pass, per the "one clearly-scoped task" pattern established in
earlier §6/§7 iterations.
### Verification Results
- `flutter test test/poll_outcome_test.dart`: 4/4 pass.
- `flutter test` (full suite): 40/40 pass, no regressions.
- `flutter analyze lib test`: zero new issues (40 pre-existing info-level
lints, none in any file touched by this change).
- **Live backend verification**: with the Docker stack running, logged in as
a real store account (`WH_JCIBBR1`), listed documents to find a real id,
then called `GET /api/v1/documents/:id` directly and confirmed the response
contains exactly the fields the new client code depends on (`parseStatus:
"done"`, `docType`, full `header`/`shipment`/`items`) — the client and
server sides were checked against each other, not just each in isolation.
Also confirmed a nonexistent id returns 404, which the existing `catch(_)`
swallows so polling continues unaffected (same behavior as before this
change for any transient GET failure).
### Menu path to see the new feature
Camera screen → capture a photo (DO or Product scan) → the pending card under
"Tertunda & Diproses" now polls `GET /api/v1/documents/:id` for that specific
document instead of the whole list — functionally invisible to the user on
the happy path (still transitions from "processing" to the review screen the
same way), but a server-side parse failure now surfaces as an immediate error
on the pending card instead of only after a 260-second timeout.
## Iteration: Full API Contract Audit + Logout Data-Loss Guard (2026-07-10)
### Context
A user-directed `e` run audited the entire Flutter↔backend request/response
contract (every call site in `lib/` against every route it hits in
`backend/pfm-web-app/src/app/api/`). Findings are written up in
`docs/api-contract-map.md` (gap IDs G1-G10) and turned into tasks: root
`plans/next-enhancements.md` §6-7 (Flutter, most blocked on backend work) and
`backend/plans/next-enhancements.md` §9 (server counterparts). This entry
covers the one task picked up and shipped from that plan via `n`: **1.3**.
### Completed Tasks
1. **Logout data-loss guard (task 1.3)**: `CameraDrawer`'s drawer logout
previously called `AuthNotifier.logout()` unconditionally — which clears
the Hive `documentBox`/`pendingDocumentsBox` and the in-memory pending
queue — with no check for unsynced work. Added `_handleLogout()` in
`lib/features/camera/camera_drawer.dart`: if `pendingDocumentsProvider`
is non-empty, shows a confirm dialog (item count, Batal/Ya-Logout) before
proceeding; an empty queue logs out immediately as before. The plan's
original file reference (`camera_screen.dart:367-370`) was stale — the
drawer had since been extracted into its own `camera_drawer.dart` file —
corrected in the plan entry.
### TDD Process
- Wrote `test/camera_drawer_logout_test.dart` first (3 cases: no pending
items → immediate logout; pending items + cancel → stays on `/camera`;
pending items + confirm → navigates to `/login`). Confirmed 2 of 3 cases
failed against the unmodified code (proving the dialog didn't exist yet),
then implemented `_handleLogout()` until all 3 passed.
- Uncovered and worked around a pre-existing, out-of-scope issue while
writing the test: `CameraDrawer`'s DO/Product Scan mode-toggle row
overflows under `flutter_test`'s default font metrics. Confirmed this is
a test-environment artifact (Google Fonts loads asynchronously and falls
back to different metrics under test than in a real running app), not a
reproducible production bug, so left it unfixed and out of scope for this
task; the test suppresses only that specific known overflow message
(`FlutterError.onError`, set inside each test body — a `setUp`-level
override doesn't work because `TestWidgetsFlutterBinding.runTest` installs
its own handler around the test body, clobbering one set earlier).
### Code Review & Audit
- **Single Responsibility**: the new logic is a single private method on
`_CameraDrawerState`, no new files needed (well under the 256-line
threshold: `camera_drawer.dart` is now ~340 lines total including the
pre-existing mode-toggle/menu code — file-size split not triggered by this
change alone since it was already over threshold pre-existing debt, per
AGENTS.md §3's "binds new/touched files going forward" — flagging for a
future pass rather than scope-creeping this task).
- **Correctness**: `mounted` is checked before both the post-dialog logout
call and the post-logout navigation, guarding against the drawer being
disposed mid-await (e.g., user backgrounds the app during the dialog).
- **No backend or contract changes** in this task — purely client-side UX/
data-integrity fix, no new endpoint calls.
### Verification Results
- `flutter test test/camera_drawer_logout_test.dart`: 3/3 pass.
- `flutter test` (full suite): 16/16 pass, no regressions.
- `flutter analyze lib test`: 41 pre-existing info-level lints (deprecated
`withOpacity`, missing `const`, etc. — all pre-dating this change), zero
new issues after removing one self-introduced `unnecessary_import` lint
in the new test file.
### Menu path to see the new feature
Camera screen → tap the hamburger/menu icon (top-left) to open the drawer →
scroll to "Logout" at the bottom. With at least one item in "Tertunda &
Diproses" (Documents screen) — i.e. anything still uploading, awaiting
review, or `syncFailed` — tapping Logout now shows a confirmation dialog
instead of logging out immediately.
## Iteration: Sync-Integrity Fix — Stop Wiping Unsynced Documents (2026-07-10)
### Context
Second task picked up from the 2026-07-10 API contract audit's root
`plans/next-enhancements.md` §6 (gap **G6** in `docs/api-contract-map.md`).
### Completed Tasks
1. **Task 6.2**: `DocumentsScreen._loadDocuments()` previously did
`documentBox.clear()` then repopulated purely from the server's `GET
/documents` response. If a document's editor save had `PUT`-failed (its
pending queue entry sits as `syncFailed`, corrected data intact there),
the next successful list refresh would silently replace the driver's
correction with the server's stale pre-edit copy at the same id — the
history entry didn't just disappear, it *reverted* to wrong data, with no
visual indication anything was off (the status badge was a hardcoded
"Terkonfirmasi" string, unconditionally).
2. Extracted the merge rule into a small, dependency-free pure function —
`mergeDocumentsWithUnsyncedOverrides()` in the new
`lib/features/documents/document_sync_merge.dart` — specifically so the
core logic (which document wins, server vs. local-corrected) is
unit-testable without standing up a fake Dio/HTTP layer. Wired it into
`_loadDocuments()`: build an id→document map from any `syncFailed`
pending items, merge over the fetched server list, persist the *merged*
result to Hive (not the raw server list), and track which ids were
overridden in new state `_unsyncedDocIds`.
3. `DocumentCard` gained an `isUnsynced` parameter (default `false`,
non-breaking for existing callers) swapping its badge between
"Terkonfirmasi" (success green) and "Belum Tersinkron" (deep orange) so
the override is visible, not silent.
### TDD Process
- Wrote `test/document_sync_merge_test.dart` first (3 cases: override wins
over a stale server doc; no-op passthrough when there are no overrides;
a local-only correction is kept even when the server list omits that id)
and `test/document_card_unsynced_badge_test.dart` (default vs. flagged
badge) — both failed to compile against the pre-change code (missing
file / missing parameter), confirming they exercised code that didn't
exist yet. Implemented until all 5 passed.
- Deliberately avoided widget-testing the full `_loadDocuments()` network
round-trip: doing so would require mocking Dio's HTTP layer (no such
pattern exists yet in this test suite, and `apiClientProvider` provides a
real `Dio` instance with no seams for canned responses). Extracting the
merge decision into a pure function sidesteps that entirely — the rule
itself is what needed correctness coverage, not the surrounding network
plumbing.
### Code Review & Audit
- **Single Responsibility**: `document_sync_merge.dart` has zero Flutter or
network imports (only `DocumentModel`) — it's a pure data-merge rule,
reusable and testable in isolation from the screen that calls it.
- **Non-breaking**: `DocumentCard.isUnsynced` defaults to `false`, so the
one other call site (none currently besides `documents_screen.dart`)
would be unaffected if added later.
- **Scope check**: did not attempt task 6.3 (never PUT to a
client-generated id) or 6.1 (blocked on backend 9.1) in this pass — kept
to the single, clearly-scoped task per the "select the most impactful"
guidance rather than bundling adjacent fixes.
### Verification Results
- `flutter test test/document_sync_merge_test.dart
test/document_card_unsynced_badge_test.dart`: 5/5 pass.
- `flutter test` (full suite): 21/21 pass (16 pre-existing + 5 new), no
regressions.
- `flutter analyze lib/features/documents lib/models test`: zero new
issues (only pre-existing `withOpacity`/`avoid_print` infos, unrelated to
this change).
### Menu path to see the new feature
Documents screen (history list) — a document whose corrections failed to
sync (visible as "syncFailed" under "Tertunda & Diproses", with a "Coba
Sinkron Ulang" retry option) now also appears in the grouped history list
below with a "Belum Tersinkron" badge showing the corrected data, instead
of either vanishing or silently reverting to the server's stale version.
## Iteration: Product Scan — Remove Fabricated Data (2026-07-10)
### Context
Third task picked up from the 2026-07-10 API contract audit. Section 7's
tasks were explained to be mostly backend-blocked (7.1 needs backend 9.2/9.3;
7.2's duplicate-classification fix needs a backend change under either
resolution of its own decision). The user asked to proceed specifically with
7.3, whose G7 half (silently-fabricated data) has no backend dependency at
all — that half is what this iteration covers.
### Completed Tasks
1. **Removed three instances of fabricated data presented as real AI/OCR
output**, all inside the Product Scan review flow
(`ProductEditorScreen`/`product_editor_logic.dart`):
- A total classification-fetch failure previously populated three
hardcoded SKU matches (`FIESTA SPICY CHICKEN NUGGET`, etc.) with fake
confidence scores (0.985, 0.82, 0.75) - indistinguishable from a real
model result. Now split into two distinct failure modes: if the SKU
master list itself can't load, there is genuinely nothing to build a
manual fallback from, so the screen shows an explicit "Gagal Memuat
Klasifikasi Produk" error with a "Coba Lagi" retry button
(`_buildFailureState()`). If only the classification call fails (SKU
list loaded fine), the screen degrades to manual SKU selection from
the real master list.
- Whenever OCR found no expiry date, two fabricated future dates
(`15/12/2026`, `20/04/2027`) were offered as selectable "batches." Now
an empty extraction leaves the batch list empty, which forces
`_isManualDate = true` so the user must enter a real date.
- Found during this pass (same bug class, same screen, not previously
catalogued as a separate gap): `ProductExpiryCard` displayed a
**literal hardcoded 92.4%** "OCR Confidence Score," unconditionally,
regardless of any actual data - confirmed via
`backend/config/classify_ocr_server.py` that the pipeline's OCR result
has no confidence field for the expiry extraction at all, so this
number could never have been real. Replaced with an honest label
reflecting whether the date came from OCR or manual entry.
2. Added `_hasAutoMatch`/`_classificationFailed` state to
`ProductEditorLogic` to distinguish "real classifier match," "no
automatic match / manual fallback," and "can't even list SKUs" as three
genuinely different states, each with its own honest UI treatment
instead of one code path that always looks the same.
3. `ProductDropdownCard` gained a required `hasAutoMatch` param - when
false, the confidence score/progress bar is replaced with "Tidak ada
rekomendasi otomatis — pilih SKU secara manual."
### TDD Process
- Wrote `test/product_dropdown_card_test.dart` and
`test/product_expiry_card_test.dart` first (pure widget tests, no network
needed - these are `StatelessWidget`s taking plain params) — both failed
to compile/assert against the pre-change widgets, confirming they
exercised the missing behavior.
- Wrote `test/product_editor_classification_failure_test.dart` third,
exploiting the fact that `flutter_test`'s sandboxed `HttpClient` always
returns 400 for real network calls - meaning `ProductEditorScreen`
pumped in a plain test environment naturally exercises the "total
failure" path with zero mocking required. Ran it against the unmodified
code first and confirmed it asserted the *old* fake SKU text was present
(proving the bug), then implemented until the test flipped to asserting
the fake text is gone and the new retry screen appears.
- This continues the pattern from the 6.2 iteration: prefer widget/pure-Dart
tests over mocking Dio, since no such mocking harness exists yet in this
suite.
### Code Review & Audit
- **Scope discipline**: this task was explicitly split from 7.3's other
half (a real `docType` field sourced from the backend's `scan_mode`),
which stays blocked and `[TODO]` - not conflated with this pass's
client-only fix.
- **File size**: `product_editor_logic.dart` grew moderately (new state
fields + restructured fetch/catch nesting) but stays well under the
256-line threshold; `product_editor_screen.dart` gained one new private
builder method, also well under threshold.
- **Correctness**: the master-SKU-list-fetch failure and the
classification-call failure are now handled by two nested try/catch
blocks specifically so a successful SKU list load isn't discarded just
because the (separate) classification call subsequently fails - the
prior code's single try/catch conflated both into one all-or-nothing
fallback.
### Verification Results
- `flutter test test/product_dropdown_card_test.dart
test/product_expiry_card_test.dart
test/product_editor_classification_failure_test.dart`: 5/5 pass.
- `flutter test` (full suite): 26/26 pass (21 pre-existing + 5 new), no
regressions.
- `flutter analyze lib/features/editor test`: two new info-level lints
introduced by this change (`prefer_final_fields` on `_skuBatches`,
missing `const` on a new `Icon`) were both fixed; final state is 19
pre-existing info-level issues, zero new ones.
### Menu path to see the new feature
Camera screen → switch mode to "Product Scan" (drawer) → capture a photo →
after upload/parse succeeds, tap the pending card to open the product
review screen. With no network reachable (or the backend down), the screen
now shows "Gagal Memuat Klasifikasi Produk" with a retry button instead of
silently presenting fake SKU suggestions as if they were real. With a
network reachable but no confident automatic match, the SKU dropdown shows
"Tidak ada rekomendasi otomatis" instead of a fake confidence bar, and a
missing expiry date requires manual entry instead of offering fake dates.
## Iteration: Real `docType` Field, Closing Task 7.3 (2026-07-10)
### Context
Fourth task from the 2026-07-10 API contract audit, and a direct follow-up
to the previous iteration. The user reported having already implemented
backend `plans/next-enhancements.md` §9 - rather than take that at face
value, verified it directly against the code before acting: read
`backend/pfm-web-app/src/utils/document-mapper.ts` and all three v1
document routes. **Confirmed 9.1 is genuinely shipped** (`mapDocumentRow()`
now returns `docType`/`parseStatus`, backed by new `scan_mode`/`parse_error`
columns, shared across list/detail/dedup responses) - **but 9.2 and 9.3 are
still `[TODO]`** (`master/skus/route.ts` GET is still admin-only; no
`v1/scan-product` route exists anywhere in the glob of `api/v1/**`). This
matters because 9.1 shipping specifically unblocks 7.3's remaining G4 half
(and separately, root task 6.1) - 9.2/9.3 are still needed for 7.1 and 7.2.
### Completed Tasks
1. **Added a real `docType` field to `DocumentModel`**
(`lib/models/document_model.dart`), read from the backend's now-present
`docType` key in `fromJson`, with a fallback to the legacy
`orderUntuk == 'PRODUCT SCAN'` sentinel check for any response or cached
Hive row that predates the backend column (mirroring the same fallback
`document-mapper.ts` itself uses, so client and server agree on legacy
data). Persisted via `toJson()` so it round-trips through Hive.
2. **Replaced every `orderUntuk == 'PRODUCT SCAN'` type check** with
`docType == 'Product'` across all 5 call sites:
`document_card.dart` (layout choice), `documents_screen.dart` (tab
filter), `pdf_service.dart` (receipt format), `product_editor_logic.dart`
(`_loadDoDocs`'s PO-candidate filter). The one remaining
`orderUntuk: 'PRODUCT SCAN'` assignment (in `_submit()`, setting the
*display* text on a newly-built product document) was left as-is - it's
legitimate display copy now - but that same construction was updated to
also explicitly set `docType: 'Product'`, so the locally-built document
is correctly typed from the moment it's created, not just once resynced
from the server.
### TDD Process
- Wrote `test/document_doctype_test.dart` covering three `fromJson` cases
(backend `docType` wins even when `orderUntuk` disagrees; legacy fallback
when `docType` is absent; default 'DO' when neither signal is present)
plus one widget regression test that specifically reproduces the bug's
original trigger: a document with `docType: 'Product'` but an `orderUntuk`
edited away from the old sentinel string must still render with the
Product layout - this is the exact scenario the old code got wrong
(editing a display field silently reclassified the document).
- All 4 cases passed on first implementation (this was a mostly-mechanical
refactor once the model field existed, so no red-then-green cycle was
needed beyond confirming the regression test's premise was sound).
### Code Review & Audit
- **Non-breaking model change**: `docType` defaults to `'DO'` in the
constructor, so none of the ~10+ existing `DocumentModel(...)`
construction call sites across the app and test suite needed updating
(verified via `flutter analyze` and the full test run - zero new
failures).
- **Consistency with the backend**: the client's fallback logic
(`docType ?? (orderUntuk == 'PRODUCT SCAN' ? 'Product' : 'DO')`)
deliberately mirrors `document-mapper.ts`'s own fallback line-for-line,
so a Flutter session reading a pre-9.1 cached document and a fresh
backend response both resolve to the same type.
- **Scope check**: did not attempt 7.1 or 7.2 in this pass - both still
need backend 9.2 and/or 9.3, which remain `[TODO]`, verified directly
rather than assumed from the user's initial "I think I already finished
section 9."
### Verification Results
- `flutter test test/document_doctype_test.dart`: 4/4 pass.
- `flutter test` (full suite): 30/30 pass (26 pre-existing + 4 new), no
regressions.
- `flutter analyze lib test`: zero new issues (40 pre-existing info-level
lints, all pre-dating this change).
### Menu path to see the new feature
Documents screen - the DO Scan / Product Scan tab split, and each card's
layout (Product name vs. Staff name as the top label; receipt format on
print) now reads a real backend-persisted field. To see the bug this fixes
would have allowed: previously, correcting a Product Scan document's
`orderUntuk` field in the editor could make it disappear from the Product
tab and reappear under DO Scan - this is no longer possible, since tab
placement no longer depends on that editable field at all.
## Iteration: Product Scan UI & Document Card Alignment (2026-07-09)
### Completed Tasks
@@ -39,3 +733,176 @@ This log tracks code review audits and QA verifications performed upon completio
### Verification Results
* **Analysis**: `flutter analyze` completed successfully with zero compile errors.
* **Testing**: Local Mock API layer verified. Simulated flows for camera review, Hive saving, relationship dropdown selections, and PDF layout checks succeed.
## Iteration: DO & Product Scan Workflow Realignment (2026-07-09)
### Completed Tasks
1. **Scanner Mode & Default Tab Synchronization**:
- Programmed `DocumentsScreen`'s `initState` to dynamically resolve the default active tab `_selectedTab` from `scanModeProvider` rather than hardcoding it to `'DO'`.
2. **Initial Document Categorization Correctness**:
- Realigned the mock/newly-captured product scan document generator in `PendingDocumentsNotifier` to use `orderUntuk: 'PRODUCT SCAN'` instead of `'REPLENISHMENT SKU'`. This prevents the scan card from incorrectly loading into the DO Scan tab and switching places only after confirmation.
3. **Pending List Filtering**:
- Filtered the in-flight pending document list on `DocumentsScreen` by the selected tab mode (`_selectedTab`), displaying pending DO documents under "DO Scan" and pending Product documents under "Product Scan" exclusively.
4. **Store-Level Data Isolation**:
- Implemented dynamic store-level data isolation by adding `clearAll()` to `LocalStorage` and calling it on user logout (`AuthNotifier.logout()`). This wipes the local cache and forces the app to fetch only the active store's records from the server on the next login session.
- Removed client-side `kepadaYth` store filters from `DocumentsScreen` to allow DO scans (which contain parent company names in `kepadaYth` rather than specific outlet names) to display correctly.
- Refactored the pending documents provider to resolve the active store profile dynamically using `SharedPreferences` for newly scanned product documents.
5. **Codebase Modularization (256-line threshold compliance)**:
- Split `lib/features/documents/documents_screen.dart` (which was at 299 lines, exceeding the 256-line limit) by extracting:
- `DocumentsTabSwitcher` into a standalone widget file `lib/features/documents/documents_tab_switcher.dart`.
- `DocumentsEmptyState` into a standalone widget file `lib/features/documents/documents_empty_state.dart`.
- Database mock data seeding logic into `DocumentsMockSeeder` under `lib/features/documents/documents_mock_seeder.dart`.
- This brought `documents_screen.dart` down to just 205 lines.
6. **Unit Test Suite Fixes**:
- Fixed outdated strings and labels in `test/login_screen_test.dart`, `test/camera_settings_test.dart`, `test/editor_validation_test.dart`, and `test/pending_queue_test.dart`.
- Setup `SharedPreferences` mock initialization and `ensureVisible` submit button tapping in widget tests.
- Refactored `MockLocalStorage` in widget tests to fully stub all Hive-touching methods, solving the uncaught `HiveError: Box not found` failures.
- Updated `test/pending_queue_test.dart` to use mock store-aligned documents so the search filters stay valid under the new store-level isolation filter.
### Code Review & Audit
* **File Size Constraint Check**:
- All touched files conform strictly to the 256-line limit:
- `lib/features/documents/documents_screen.dart` is exactly 205 lines.
- `lib/features/documents/documents_tab_switcher.dart` is 56 lines.
- `lib/features/documents/documents_empty_state.dart` is 21 lines.
- `lib/features/documents/documents_mock_seeder.dart` is 87 lines.
- `lib/features/documents/pending_documents_provider.dart` is 229 lines.
### Verification Results
* **Analysis**: `flutter analyze` completed successfully with zero compile errors.
* **Testing**: `flutter test` executed successfully. All 13 tests passed perfectly with zero regressions in both the DO and Product Scan suites.
---
## Iteration: Product Editor onto the Authenticated v1 Surface, Closing Task 7.1 (2026-07-10)
### Context
Backend tasks 9.2 (relaxed `GET /api/v1/master/skus` to any authenticated
account) and 9.3 (new authenticated `POST /api/v1/scan-product`) both shipped
this session, unblocking task 7.1 (gap **G2**, `docs/api-contract-map.md`):
`product_editor_logic.dart` was reaching the classify+SKU-match pipeline via
`AppConfig.apiBaseUrl.replaceAll('/api/v1', ...)` to call the classic,
unauthenticated `GET /api/skus` and `POST /api/scan-pfm` dev routes — routes
that backend task 4.5 already excluded from the public ngrok tunnel, so
product scanning was documented as broken off-LAN.
### Completed Tasks
1. **New endpoint constants** (`lib/config/app_config.dart`):
`masterSkusEndpoint = '/master/skus'`, `scanProductEndpoint = '/scan-product'`,
alongside the existing `fetchDocumentsEndpoint` etc.
2. **Rewired `_fetchClassificationAndSkus`** (`product_editor_logic.dart`) to
call both v1 endpoints via the shared `apiClientProvider` Dio instance
directly (no more base-URL string hack) — the `Authorization` header is
already attached automatically by `ApiClient`'s request interceptor
(`api_client.dart:32-41`), exactly like every other v1 call site in this
file (`_submit()`'s `PUT`).
3. **Switched the classification request from base64 JSON to multipart** —
`FormData.fromMap({'image': await MultipartFile.fromFile(...)})`, the same
pattern already proven for DO uploads in
`pending_documents_provider.dart:89-94`. This is what backend 9.3 was
explicitly built to prefer (its own task description calls out "the client
currently ships a multi-MB base64 JSON body" as the thing to fix).
4. **Extracted the v1-envelope-unwrapping logic** into a new pure file,
`lib/features/editor/product_scan_response_parser.dart`
(`parseSkuMasterList`, `parseScanProductResponse` + `ScanProductResult`) —
no Flutter/network imports, mirroring task 6.2's `document_sync_merge.dart`
pattern specifically so a wrong-envelope-shape bug is caught by a plain
unit test instead of only surfacing at runtime against a real server.
### TDD Process
- Wrote `test/product_scan_response_parser_test.dart` (6 cases: SKU list
happy path, empty array, missing `data` key; scan response happy path,
empty `possibleMatches`, missing `ocr` entirely) **before** creating
`product_scan_response_parser.dart` — confirmed all 6 failed to compile
(`Method not found`) against the not-yet-existing functions, then
implemented the parser and reran: all 6 passed on the first implementation.
- Left the existing `test/product_editor_classification_failure_test.dart`
untouched — it exercises the real-network-failure path (no server reachable
in the test sandbox), which doesn't depend on which URL is being called; a
full test run confirmed it still passes unchanged.
### Code Review & Audit
- **Removed the `replaceAll` hack entirely**, per the task's explicit
acceptance criteria — no remaining reference to `/api/skus` or
`/api/scan-pfm` anywhere in `product_editor_logic.dart`.
- **Dropped a now-fully-unused import**: `dart:convert` (only `base64Encode`
used it, which no longer exists in this file after the multipart switch) —
removed from `product_editor_screen.dart` rather than left dangling.
- **Error-handling semantics unchanged from task 7.3**: SKU-list fetch
failure still fully blocks the screen (`_classificationFailed = true`);
classification-call failure alone still degrades to manual selection from
the real master list. Only the transport (multipart vs. base64) and parsing
(shared pure functions vs. inline) changed, not the failure-handling
decisions made in the previous iteration.
- **Live verification against the real backend** (the Docker stack from the
concurrent backend session was still running): confirmed with a real store
account's bearer token that `GET /api/v1/master/skus` returns 232 real SKUs
in the exact `{status,data:[...]}` shape the new parser expects, and that
`POST /api/v1/scan-product`'s multipart branch (verified during backend
9.3's own iteration) returns the exact `{status,data:{classification,ocr,
possibleMatches}}` shape this task's parser consumes — the client and
server sides were checked against each other, not just each in isolation.
### Verification Results
- `flutter test test/product_scan_response_parser_test.dart`: 6/6 pass.
- `flutter test` (full suite): 36/36 pass, no regressions.
- `flutter analyze lib`: zero new issues (pre-existing info-level lints only,
none in any file touched by this change).
### Menu path to see the new feature
Camera screen → switch scan mode to "Product" → capture a photo → the
Product Editor review screen's SKU dropdown and AI-suggested match now come
from the authenticated `/api/v1/master/skus` and `/api/v1/scan-product`
endpoints instead of the old dev-only routes — this is what makes product
scanning work through the public ngrok tunnel (off-LAN), not just on the same
Wi-Fi network as the backend.
---
## Iteration: Task 8.1 — Global Scan-Mode State + DO/Product Color Cue (2026-07-10)
### Context
Task 8.1 from `plans/next-enhancements.md` §8, sourced from APK release testing feedback
in `twinkly-riding-mitten.md`. Root cause documented as gap **G11** in
`docs/api-contract-map.md`.
### Problem Addressed
`DocumentsScreen` cached the active tab as a local `String _selectedTab`, seeded once from
`scanModeProvider` in `initState()`. After that point the two diverged: switching mode in the
camera drawer updated `scanModeProvider`, but the documents screen still showed whatever tab
it was initialised with. The fix makes `scanModeProvider` the sole writer/reader for both
surfaces. As a secondary fix, the orange DO mode color was scattered as an ad-hoc
`Colors.orange.shade700` literal; now centralised as `AppConfig.doModeColor`.
### Files Changed
| File | Change |
|---|---|
| `lib/config/app_config.dart` | +`doModeColor = Color(0xFFF57C00)` constant |
| `lib/features/documents/documents_screen.dart` | Removed `_selectedTab`; reads `ref.watch(scanModeProvider)` in `build()`; `onTabChanged` writes to provider |
| `lib/features/documents/documents_tab_switcher.dart` | Active DO tab color: `doModeColor`; active Product: `primaryColor` |
| `lib/features/documents/document_card.dart` | DO category label: `doModeColor` (was inline `Colors.orange.shade700`) |
| `lib/features/camera/camera_drawer.dart` | Refactored to 232 lines — mode toggle extracted, helpers extracted |
| `lib/features/camera/camera_drawer_mode_toggle.dart` | **NEW** 125 lines — DO/Product pill, owns `scanModeProvider` writes, applies `doModeColor` to DO segment |
| `lib/features/camera/camera_drawer_helpers.dart` | **NEW** 78 lines — section header, drawer item, dialogs |
| `test/scan_mode_color_test.dart` | **NEW** 7 widget tests (color cues + provider write-through) |
### §3 Compliance
`camera_drawer.dart` was touched and was 413 lines → split into 3 files totalling 435 lines
across narrower, single-purpose modules. Each new file is under 256 lines.
### Test Results
- **New tests**: 7/7 pass (`test/scan_mode_color_test.dart`)
- **Regression**: `test/camera_drawer_logout_test.dart` 3/3 pass (verified drawer refactor
preserved all logout behavior including the `pendingCount` interpolation in the dialog)
- **Full suite**: 50/50 pass — zero regressions
- `flutter analyze` on changed files: 0 errors, infos only (pre-existing `withOpacity`
deprecation across codebase, not introduced by this task)
### QA Notes
- `doModeColor = Color(0xFFF57C00)` is exactly `Colors.orange.shade700` — verified by
comparing the hex value from Flutter source. No visual change to `DocumentCard`; only the
constant name changed.
- `DocumentsMockSeeder` (if present) initialises `scanModeProvider` from its own logic —
not affected, mock seeder does not set tab state.
- The `_selectedTab` removal is a pure refactor: `ConsumerStatefulWidget.ref.watch()` in
`build()` is the idiomatic Riverpod pattern; `setState` is no longer needed for tab switching.