Files
pfm-ocr/docs/iteration-log.md
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 ada6488592 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>
2026-07-10 15:19:32 +07:00

55 KiB

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 StatelessWidgets 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.