# Iteration Log 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 1. **Product Scan Flow UI & Dropdowns**: - Built a custom `ProductEditorScreen` featuring a crop preview region, AI confidence bar, and product SKU classification dropdown. - Implemented a fuzzy expiry batch selector dynamically matched to the active SKU. If "Set Tanggal Manual..." is chosen, a date picker dialog is triggered. - Built a local storage Hive save mechanism storing scan results under the `PRODUCT SCAN` type, displaying them in history. 2. **Tab Switcher & Layout Alignment**: - Replaced the three-tab switcher in `DocumentsScreen` with a clean two-tab switcher: **DO Scan** and **Product Scan** (defaulting to DO Scan, removing "Semua"). - Redesigned `DocumentCard` to present matched visual structure for both document types: - Top category label: Staff Name for DO Scan (Orange), Product Name for Product Scan (Green). - Main Title: PO Number for DO Scan (directly without prefix), Batch Number for Product Scan (`[Batch Name]`). - Subtitle Details: `[Date] • [Item Count] Item` for DO cards, and `[Expiry Date] • [PO Number]` (without labels) for Product cards. 3. **Custom Receipt PDF**: - Refactored `PdfService` to render a custom document receipt tailored to Product Scan (titled "BUKTI VERIFIKASI SCAN PRODUK", showing verification information, parameters table, and staff signatures) while preserving the same consistent professional layout styling as the DO Scan receipt. 4. **PO Relationship Mapping**: - Integrated a dynamic PO relationship dropdown inside the `ProductEditorScreen`, allowing the user to select any confirmed DO Scan PO number in Hive storage to link the product scan to its original DO document. 5. **Active Account Seeding**: - Programmed dynamic seeding of 1 initial mock DO Scan document and 1 mock Product Scan document for the active store profile if no entries exist in Hive. ### Code Review & Audit * **Single Responsibility Principle**: - `ProductEditorScreen` manages the review interface cleanly. - `PdfService` encapsulates both delivery and product receipt layout rules dynamically using a boolean check (`isProductScan`). - `DocumentCard` resolves layout colors and labels cleanly without redundant widgets. * **File Size Constraint Check**: - All modified files remain strictly under the 256 lines limit: - `lib/features/editor/product_editor_screen.dart` is exactly 249 lines. - `lib/features/documents/document_card.dart` is 133 lines. - `lib/features/editor/pdf_service.dart` is 214 lines. * **Code Integrity**: - Null safety is fully respected across Riverpod providers and shared preferences lookups. - State lifecycle is cleanly managed when showing picker dialogues. ### 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.