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