# Next Enhancements (Flutter) This file is the working backlog driven by the `e`/`enhance` and `n`/`next` triggers defined in [AGENTS.md](../AGENTS.md), which now scopes this kit's automated behaviors to the **Flutter app only** (see AGENTS.md's "Scope: excludes `backend/`"). Sections seeded 2026-07-08 from the real module structure of `app-pfm-ocr-v2` (see AGENTS.md's Adaptation Notes); tasks populated the same day via `e`/`enhance`, grounded in a direct read of each module's current code rather than invented work. > `backend/` has its own, independent copy of this kit — > [backend/plans/next-enhancements.md](../backend/plans/next-enhancements.md), driven > by `backend/AGENTS.md` Part B. This file no longer tracks backend work at all — > the backend sections that briefly lived here (5-8, from the one backend-scoped `e` > run before the kit split) were removed 2026-07-08 now that the backend copy is the > sole active backlog for that subtree. > Note: this repo already has an unrelated, pre-existing `plans/next-enhancement-plan.md` > (singular) — a `[DONE]` QA verification checklist. It is not part of this workflow > and is left as-is; this file (plural) is the one `e`/`n` reads and writes. ## Format Tasks are grouped under a numbered section per module of the application. Each section gets exactly 3 tasks: ``` ## 1.
- **1.1** [TODO] - **1.2** [TODO] <...> - **1.3** [TODO] <...> ``` When a task is picked up via `n`/`next`, its clarified acceptance criteria (from AGENTS.md §2a) are appended directly under it as a short note, e.g.: ``` - **1.1** [TODO] - Acceptance: <1-3 line resolved scope, from the clarification step> ``` When complete, the status flips to `[DONE]` and the feature is logged in [docs/feature-list.md](../docs/feature-list.md). --- ## Sections (seeded from real modules — run `e` / `enhance` to fill in tasks) ### 1. Flutter — Auth & Splash `lib/features/auth/`, `lib/features/splash/` - **1.1** [TODO] Harden the token validation that now exists. *(Description refreshed 2026-07-10 — the original claim "never pings the server" is stale: `checkLoginState()` now calls `GET /auth/me` and logs out on 401.)* Remaining gap (see [docs/api-contract-map.md](../docs/api-contract-map.md) **G9**): 401 detection is `e.toString().contains('401')` (`auth_provider.dart:30`) instead of reading `ApiException.from(e).statusCode`, and any non-401 failure (timeout, 500, dead tunnel) silently treats the user as logged in with a possibly-stale cached profile. Use the structured exception, and decide/handle the offline-start case explicitly. - **1.2** [TODO] Add a global 401/403 response interceptor to the Dio client that force-logs-out and redirects to `/login`, so an expired/revoked token surfaces as a clear re-login prompt instead of failing whatever screen happens to make the next API call. - **1.3** [DONE 2026-07-10] Warn before logout if the pending documents queue (`pendingDocumentsProvider`) has unsynced items. *(File reference corrected: the drawer now lives in `lib/features/camera/camera_drawer.dart`, not `camera_screen.dart` — extracted in a later commit.)* Acceptance: if the queue is non-empty when Logout is tapped, show a confirm dialog ("Ada Dokumen Belum Tersinkron" / item count / Batal-or-Ya-Logout) before calling `logout()`; if empty, logout proceeds immediately as before. Implemented in `CameraDrawer._handleLogout()` (`camera_drawer.dart`). See docs/feature-list.md. - **1.4** [TODO] Warn before login wipes local state, mirroring 1.3's logout guard. `AuthNotifier.login()` (`auth_provider.dart:62-65`) unconditionally calls `localStorage.clearAll()` and `pendingDocumentsProvider.clearQueue()` *before* saving the new session — if there's a non-empty pending/unsynced queue at the moment of login (re-login after a forced logout, or a shared device), it's silently destroyed with zero recovery path, unlike the logout path which now confirms first. - **1.5** [TODO] Stop sequencing the splash screen's fixed delays ahead of the network-dependent auth check. `_checkAuthAndNavigate()` (`splash_screen.dart:32-43`) always waits the full 2s fade + a hardcoded extra 1s "for effect" *before even starting* `checkLoginState()`'s `/auth/me` round trip — every cold launch pays ~3s of artificial delay on top of real network latency. Run the animation and the auth check concurrently (`Future.wait`) and navigate as soon as both finish. - **1.6** [TODO] Add an error boundary around the splash screen's auth check. `_checkAuthAndNavigate()` calls `await ref.read(authProvider.notifier).checkLoginState()` with no try/catch — an exception from `SharedPreferences.getInstance()` or elsewhere in that call chain propagates uncaught, and since neither `context.go('/camera')` nor `context.go('/login')` ever executes, the user is stuck on the splash screen indefinitely with no visible error and no way forward except force-closing the app. ### 2. Flutter — Camera Capture & Geotagging `lib/features/camera/` - **2.1** [TODO] Surface actionable, user-visible feedback when location can't be determined, instead of only logging it. Every failure path in `LocationService.determinePosition()` (`lib/core/location/location_service.dart`) — services disabled, permission denied, `deniedForever`, or all four GPS-fix strategies failing — only calls `debugPrint` and returns `null`; the driver gets no on-screen prompt (e.g. "enable location" / "open app settings") and the document just uploads without a GPS tag. - **2.2** [TODO] Show a location-fix quality/staleness indicator on the capture screen before the shutter is pressed. `_currentPosition` in `CameraScreen` (`camera_screen.dart:22,37-62`) is used whatever its age or accuracy, with no on-screen warning when no fix has landed yet or the fix is old — a document can silently upload with a poor or missing GPS tag. - **2.3** [TODO] Replace the static "posisikan seluruh halaman dokumen di dalam foto" instructional text with a live document-alignment overlay during capture. `CameraScreen` only launches the OS's native camera app via `ImagePicker(source: ImageSource.camera)` (`camera_screen.dart:69-74`) — there's no in-app camera preview, so the framing guideline is shown once beforehand and then unavailable during the actual shot. - **2.4** [TODO] Enforce the blur check instead of only displaying it. `ImagePreviewScreen`'s "Unggah Dokumen" button (`image_preview_screen.dart:286-294`) is gated only on `!_analyzing` — not on `!_blurResult!.isBlur` — so a photo flagged "Foto Terdeteksi Blur! Diharuskan untuk mengambil ulang gambar" can still be uploaded as-is. The blur badge is currently cosmetic. - **2.5** [TODO] Flag or restrict gallery-sourced captures. `_pickFromGallery()` (`camera_screen.dart:96-144`) lets staff select any existing photo from the device gallery, not just a fresh capture — if it has no EXIF GPS and no live position is cached, it uploads with no geotag and nothing on the document distinguishes it later from a live camera capture, undermining the GPS tag's purpose as proof the staff was physically present. - **2.6** [TODO] Surface camera/gallery picker failures to the user. `_takePicture()`/`_pickFromGallery()` (`camera_screen.dart:91-93,141-143`) catch all `image_picker` errors (permission denied, picker cancelled abnormally, IO failure) with only `debugPrint` — tapping "Ambil Foto Kamera"/"Pilih Dari Galeri" can silently do nothing with zero on-screen explanation of why. ### 3. Flutter — Pending Documents Queue `lib/features/documents/` - **3.1** [DONE] Persisted the pending documents queue to disk via a new Hive box (`LocalStorage.pendingDocumentsBox`/`savePendingDocument`/`removePendingDocument`/`getAllPendingDocuments`, `lib/core/storage/local_storage.dart`). `PendingDocumentsNotifier` now hydrates from disk on construction and resumes anything not yet terminal: a persisted `uploading` item (fresh capture, or a `retryUpload` interrupted mid-flight) re-runs `_uploadAndProcess` from scratch — safe because the upload endpoint dedupes by file hash server-side — and a persisted `processing` item resumes polling via the newly extracted `_pollUntilParsed`/`_resumePolling` instead of re-uploading. `retrySync`'s own transient flip to `uploading` is deliberately kept in-memory-only (`_updateItemInMemory`) so an interrupted sync-retry resumes as "resend the PUT," not "redo the whole upload." Storage failures are caught and swallowed everywhere (`hydrate`, `_persistPendingDocument`, `_removePersistedPendingDocument`) so a Hive error degrades to the old in-memory-only behavior rather than crashing the queue. Verified via `flutter analyze` (clean) and `flutter test` (no new failures vs. the pre-existing 3-test baseline). Completed 2026-07-08. - **3.2** [TODO] Add a persistent "pending/syncing count" badge visible from the camera screen (not just the `/documents` list), reflecting `pendingDocumentsProvider` state — today a driver who navigates away from `/documents` gets no visibility into background uploads still in progress or stuck in `error`/`syncFailed`. - **3.3** [TODO] Right-size per-request timeouts. *(Description refreshed 2026-07-10 — the original claim "no explicit connect/receive timeout" is stale: `ApiClient` now sets `connectTimeout` 10s / `receiveTimeout` 240s globally, `api_client.dart:14-19`.)* Remaining gap: the 240s receive timeout is sized for the upload's synchronous OCR pass but is inherited by *every* call — a hung 2s-interval poll `GET /documents` can stall one iteration for up to 4 minutes, and login/list calls hang far longer than useful. Pass tighter per-request `Options(receiveTimeout: ...)` on the poll/list/login paths, keeping the long timeout only where the slow parse justifies it. Coordinate with 5.1 (failover needs fast failure). - **3.4** [TODO] Clean up orphaned captured-image files. Neither `removeDocument()` nor the success path in `_uploadAndProcess()` (`pending_documents_provider.dart`) ever deletes the underlying photo file at `imagePath` — only the Hive record is removed. Every synced or manually-dismissed document leaves its captured image on device storage indefinitely, growing unbounded over the app's lifetime. - **3.5** [TODO] Stop reporting network failures as "OCR timeout." `_pollUntilParsed()`'s per-iteration catch (`pending_documents_provider.dart:142`) silently swallows every exception (including plain connectivity errors) and just retries; if all 130 retries are consumed this way, the final message is always the generic "Gagal mengekstrak data (Timeout)." — masking a bad Wi-Fi connection as a backend/OCR problem and making real issues harder to diagnose in the field. - **3.6** [TODO] Throttle pending-queue processing on load instead of firing everything at once. `_loadPendingDocuments()` (`pending_documents_provider.dart:22-36`) starts an independent upload-or-poll loop for *every* non-terminal item simultaneously on provider construction — if a store queues many documents while offline and then reconnects, the app fires that many concurrent multipart uploads and 2s-interval polling loops at once against a single-GPU backend already flagged as a throughput bottleneck. ### 4. Flutter — Document Editor & PDF Receipt `lib/features/editor/` - **4.1** [TODO] Add an unsaved-changes guard when navigating away from `EditorScreen` with edited-but-unsaved field values. There is currently no `PopScope`/back-navigation interception, so a back-swipe or system back button silently discards manual corrections to OCR'd header/item fields. - **4.2** [TODO] Block save when a line item's SKU isn't in the master registry, instead of only relabeling it for display. The SKU listener in `_addItem` (`editor_screen.dart:108-115`) sets the item name to "SKU Tidak Terdaftar" for an unrecognized SKU but doesn't stop form submission, so a document with an unregistered/mistyped SKU can still be saved and its receipt printed. - **4.3** [TODO] Include the captured GPS coordinates on the printed PDF receipt. `PdfService.generateAndPrintReceipt` (`pdf_service.dart:36-52`) prints header/shipment/item fields but never includes `document.latitude`/`longitude`, even though the editor captures and displays them (`_latitudeCtrl`/`_longitudeCtrl`) — the geotag exists in the data model but isn't part of the audit-trail document a store keeps. - **4.4** [TODO] Block save on a document with zero line items. `_showConfirmationDialog()` (`editor_logic.dart:94-117`) only runs `_formKey.currentState!.validate()` (per-field validators) — nothing checks `_itemControllers.isNotEmpty`. If every item is removed via `_removeItem`, the form still validates and the document saves/prints with an empty items list. - **4.5** [TODO] Stop validating DO items against a static bundled SKU list. The item SKU field's validator (`items_list_card.dart:85`) checks membership in `MasterSku.data` — a hardcoded `Map` compiled into the app (`lib/data/master_sku.dart`) — instead of the live `/api/v1/master/skus` endpoint the Product Scan flow already uses (task 7.1). Any SKU added/changed centrally won't validate correctly in the DO editor until the app is rebuilt and redeployed to every device, and the bundled copy can silently drift from the real master data. - **4.6** [TODO] Strengthen delivery confirmation beyond a typed name. `ConfirmationDialog` (`confirmation_dialog.dart`) accepts any freely-typed string as `namaPenerima` plus a checkbox — nothing verifies the person confirming is who they claim, yet that typed name is printed on the PDF receipt's signature line (`pdf_service.dart`) as if it were an actual signature. Consider a lightweight identity check (PIN re-entry, or a captured signature/initial) before treating it as a confirmed receipt. ### 5. Flutter — Connectivity & Endpoint Resolution `lib/config/app_config.dart`, `lib/core/network/api_client.dart` Added 2026-07-08 via a user-directed `e` run ("dual endpoint: local first, public ngrok fallback"). **Current-state audit**: the requested dual-endpoint fallback *already exists at startup* — `AppConfig.initializeApiBaseUrl()` (`app_config.dart:32-49`) probes the LAN URL first, falls back to the reserved ngrok domain, and validates each probe is a *real* backend (checks the `ngrok-error-code` header, JSON content-type, and 502/503/504) with the `ngrok-skip-browser-warning` header set. (Root `CLAUDE.md` describes this order backwards — tracked as a doc fix in backend task 5.1d.) These tasks close what's actually missing: - **5.1** [TODO] **Mid-session endpoint failover.** Resolution runs exactly once at startup, and `ApiClient` freezes `baseUrl` at construction of a singleton (`api_client.dart:13`, `apiClientProvider`) — a phone that resolves LAN on Wi-Fi and then leaves the building fails every subsequent call with no path back to the ngrok endpoint (and vice versa) until an app restart. Add a Dio interceptor that, on *connectivity-class* failures only (`connectionTimeout`/ `connectionError` — not HTTP-level errors), re-runs endpoint resolution and retries the request once against the newly resolved endpoint. Implementation notes: the frozen-at-construction `baseUrl` must become dynamic (set `_dio.options.baseUrl` on re-resolution, or read `AppConfig.apiBaseUrl` in the existing `onRequest` interceptor); retry-once is safe for the upload path because the backend dedups by `file_hash` (backend task 1.1), but audit other POST/PUT call sites before blanket-retrying. Coordinate with 3.3 (explicit Dio timeouts) — a hung connection must fail fast enough for failover to matter. - **5.2** [TODO] **Endpoint status visibility + manual re-probe.** Show which endpoint the app is on (LAN / Public / unreachable) as a small persistent indicator (camera screen or drawer) with a tap-to-re-probe action, so a driver or tester can see and fix "wrong/stale endpoint" in the field without reading logs — a stale tunnel is the documented first failure point for login/upload (root `CLAUDE.md`). Re-probe reuses 5.1's resolution path. - **5.3** [TODO] **Make both endpoint URLs configurable without a code edit.** `_lanBaseUrl` and `_ngrokBaseUrl` are compile-time consts — the LAN one is regex-patched by `start-dev-tunnel.ps1` (which breaks silently if the const is renamed/moved; the script warns but the app still ships the stale IP), the ngrok one requires a manual source edit if the reserved domain ever changes. Add a runtime override (e.g. long-press-hidden settings sheet writing to `SharedPreferences`, seeded from the compiled defaults) so a field device can be repointed without rebuilding the APK. Keep the script's patch working (or teach it to fail loudly — backend task 4.3 covers verifying the tunnel end). Once backend task 1.6 ships `GET /api/v1/health`, switch `_isBackendReachable`'s probe to it — probing `POST /auth/login` with an empty body works but couples reachability to the login route's error shape. - **5.4** [TODO] Stop unconditionally logging sensitive data. `LogInterceptor(requestBody: true, responseBody: true)` (`api_client.dart:44-47`) logs every full request/response body — including the `Authorization: Bearer` token and personal data (driver/receiver names, GPS coordinates, DO contents) — via Dio's default `print`, which is not gated behind `kDebugMode` and will also run in release builds, exposing tokens and PII to on-device logs (`adb logcat`) in the field. - **5.5** [TODO] Add proactive connectivity-change detection to complement 5.1's reactive failover. There's no OS-level network-change listener (e.g. `connectivity_plus`) — the app only discovers a stale endpoint after a request already times out. Listening for WiFi↔cellular transitions would let it re-resolve the base URL immediately instead of waiting for the next failed call. - **5.6** [TODO] Encrypt the LAN leg of traffic. `_lanBaseUrl` (`app_config.dart:22`) is plain `http://`, meaning the bearer token and all document contents travel unencrypted over the store's local Wi-Fi whenever the LAN endpoint is active — anyone else on that network can passively sniff credentials or DO data. The ngrok leg is already HTTPS; the LAN leg has no equivalent protection. ### 6. Flutter — API Contract & Sync Integrity (DO flow) `lib/features/documents/`, `lib/models/document_model.dart`, `lib/core/network/` Added 2026-07-10 via a user-directed `e` run auditing the full frontend↔backend request/response contract. **Read [docs/api-contract-map.md](../docs/api-contract-map.md) first** — it maps every Flutter call site to its backend route, documents both envelopes and the DO document lifecycle, and defines the gap IDs (G1-G10) cited below. Backend counterparts live in `backend/plans/next-enhancements.md` §9 and generally must ship first. - **6.1** [DONE 2026-07-10] **Poll a single document with a real parse status instead of scanning the whole list** (G1, G10 client half; unblocked once backend 9.1 shipped `GET /api/v1/documents/:id`). `_pollUntilParsed` (`pending_documents_provider.dart`) now calls `GET /documents/:id` every 2s for the specific pending item's own id instead of fetching and scanning the entire `GET /documents` list — one row + one `ocr_items` query per poll, flat regardless of history size, instead of the old N+1 across the whole list. Added a `parseStatus` field to `DocumentModel` (nullable, populated only by this endpoint) and extracted the branching decision into a new pure function, `resolvePollOutcome()` in `lib/features/documents/poll_outcome.dart` (no Flutter/network imports, mirroring task 6.2's `document_sync_merge.dart` / task 7.1's `product_scan_response_parser.dart` pattern): `parseStatus: "done"` -> success with the fetched document; `"failed"` -> immediate error with an explicit message instead of waiting out the full 260s timeout; `"pending"` or absent (legacy/cached response) -> keep polling. Deleted the old object-identity match trick (`found != doc`). Tests: `test/poll_outcome_test.dart` (4 cases: done/failed/pending/legacy-null). Verified live against the running backend: logged in as a real store account, confirmed `GET /api/v1/documents/:id` for a real document returns exactly the shape `DocumentModel.fromJson`/`resolvePollOutcome` expect (`parseStatus`, `docType`, full header/shipment/items), and that a nonexistent id 404s (handled by the existing catch, polling continues, same as before). See docs/feature-list.md. - **6.2** [DONE 2026-07-10] **Stop wiping locally-saved-but-unsynced documents on refresh** (G6). Acceptance (resolved without a Grill-Me pass — the task's own description already specified the approach): a document with a `syncFailed` pending entry keeps its corrected local version in the history list — visibly flagged, not silently reverted to the server's stale pre-edit copy or dropped. Implemented via a pure, unit-tested merge function, `mergeDocumentsWithUnsyncedOverrides()` in the new `lib/features/documents/document_sync_merge.dart` (deliberately a plain-Dart file with no Flutter/network imports, so the merge rule itself is testable without mocking Dio): for each server-fetched document, a matching `pendingDocumentsProvider` entry with `status == syncFailed` overrides it with the locally-corrected copy (and is kept even if the server list omits that id entirely). `documents_screen._loadDocuments` now persists the *merged* list instead of the raw server list, and tracks which ids were overridden in `_unsyncedDocIds`. `DocumentCard` gained an `isUnsynced` param that swaps its previously-hardcoded "Terkonfirmasi" badge for "Belum Tersinkron" (deepOrange) when set. Tests: `test/document_sync_merge_test.dart` (3 cases: override wins, no-op passthrough, local-only doc kept though absent server-side), `test/document_card_unsynced_badge_test.dart` (2 cases: default vs. flagged badge). See docs/feature-list.md. - **6.3** [DONE 2026-07-10] **Never PUT to a client-generated ID** (G5). - Acceptance (resolved via Grill-Me): when no resolved server document is available, auto-recover by re-uploading the pending item's local image to get a real server-assigned id (safe: server dedups by `file_hash`), then PUT the corrected fields to that real id — rather than blocking outright. G8 (moving `pendingId` off `GoRoute`'s `state.extra`) is explicitly kept out of scope for this pass; it stays open as its own future task. - Both editors previously built `finalDoc.id = _document?.id ?? widget.pendingId ?? now-ms` (`editor_screen.dart` save, `product_editor_logic.dart`); when `_document` was null the PUT targeted `/documents/<13-digit-timestamp>`, which can never match the int4 `documents.id` — the item looped in `syncFailed` forever with no path to recovery. - Extracted the guard into a new pure decision function, `resolveDocumentSaveAction()` in `lib/features/editor/document_save_action.dart` (no Flutter/network imports, same pattern as task 6.1's `poll_outcome.dart`): a resolved `existingDocument` -> PUT to its real id (unchanged happy path); no document but a local image path -> `reuploadThenPut` (re-upload via the same multipart pattern used elsewhere, then PUT); no document *and* no local image -> `blocked` with an explicit user-facing message instead of silently fabricating an id. Both `editor_screen.dart`'s `_submitDocument()` and `product_editor_logic.dart`'s `_submit()` now call this function and branch on its result identically. - **§3 file-size compliance**: touching `editor_screen.dart` (381 lines) and `product_editor_logic.dart` (296 lines) put both over the 256-line threshold, so both were split as part of this change (AGENTS.md §3 binds touched files, not just new ones). `editor_screen.dart` was split into a widget-only file (150 lines) plus a new `editor_logic.dart` mixin (238 lines, `EditorLogic on ConsumerState`), mirroring the part-file pattern the product editor already used. `product_editor_logic.dart` itself was replaced by two smaller part files along the same seam it already had internally (data-loading vs. submit): `product_editor_data_logic.dart` (171 lines, `ProductEditorDataLogic`) and `product_editor_submit_logic.dart` (128 lines, `ProductEditorSubmitLogic on ProductEditorDataLogic`). - Tests: `test/document_save_action_test.dart` (3 cases: putExisting, reuploadThenPut, blocked). Verified live against the running backend: ran the full recovery sequence by hand (multipart re-upload of a real test image -> real server id returned -> PUT corrected fields to that id -> GET confirms the correction persisted), proving the recovery path is a genuine save, not a dead end. See docs/feature-list.md. - **6.4** [TODO] Fix `DocumentModel.toJson()` silently dropping fields on every local persist. `toJson()` (`document_model.dart:124-153`), used for *all* Hive writes (pending queue and the confirmed `documentBox`, including `documents_screen.dart`'s clear-and-resave on every refresh), omits `confirmed`, `parseStatus`, `productScanMatches`, and `productScanExtractedExpiryDate`. Any document reloaded from local cache silently reverts `confirmed` to its default `true` and loses task 7.2's stored classification data, defeating both the confirmation gate and the "don't reclassify" optimization specifically in the cached/offline path. - **6.5** [TODO] Add an offline/stale-data indicator to the documents list. `_loadDocuments()`'s network fetch failure (`documents_screen.dart:87-89`) is swallowed with only a `debugPrint` — a store on a dead connection sees the same "Dokumen" list with no on-screen indication it might be showing stale cached data rather than the current server state. - **6.6** [TODO] Extend document search to item contents. `_onSearchChanged()` (`documents_screen.dart:92-103`) filters only on header fields (`noDo`/`noPo`/`noSo`/`tanggal`/`kepadaYth`) — a staff member searching for a document by product/SKU name gets no results, even though that's a natural way to look up "that frozen chicken delivery from last week." ### 7. Flutter — Product Scan Review Flow `lib/features/editor/product_editor_screen.dart` + `product_editor_logic.dart` + `widgets/product_*`, `lib/features/camera/scan_mode_provider.dart` Added 2026-07-10, same contract audit (gap IDs from [docs/api-contract-map.md](../docs/api-contract-map.md)). The product review flow shipped 2026-07-09 works on the happy path but is wired to non-production endpoints and placeholder data. - **7.1** [DONE 2026-07-10] **Move the product editor onto the authenticated `/api/v1/*` surface** (G2). `_fetchClassificationAndSkus` now calls `GET AppConfig.masterSkusEndpoint` (`/master/skus`) and `POST AppConfig.scanProductEndpoint` (`/scan-product`) — both new endpoint constants in `app_config.dart` — instead of string-hacking the base URL to reach the classic unauthenticated `/api/skus`/`/api/scan-pfm` dev routes. The `Authorization` header is attached automatically by `ApiClient`'s existing request interceptor (`api_client.dart:32-41`), same as every other v1 call site. Also switched the classification call from a base64 JSON body to multipart (`FormData`/`MultipartFile`, the same pattern already used for DO uploads in `pending_documents_provider.dart`) — backend 9.3 was built to prefer this specifically to avoid shipping a multi-MB base64 payload. Extracted the "unwrap the v1 `{status,data}` envelope" logic into a new pure file, `lib/features/editor/product_scan_response_parser.dart` (`parseSkuMasterList`/`parseScanProductResponse`), mirroring task 6.2's `document_sync_merge.dart` pattern so the parsing logic is unit-testable without mocking Dio. Tests: `test/product_scan_response_parser_test.dart` (6 cases covering both envelope shapes, empty lists, and missing `ocr`). Verified live against the running backend stack: a real store account's token succeeds against `GET /api/v1/master/skus` (232 real SKUs, matching envelope shape) and `POST /api/v1/scan-product` (multipart, real classification + top-5 matches) — see backend docs/iteration-log.md's task 9.3 entry for the matching server-side verification. Full `flutter test` suite (36 tests) and `flutter analyze lib` clean, no regressions. See docs/feature-list.md. - **7.2** [DONE 2026-07-10] **Classify each product photo once, not twice** (G3). Resolved via user feedback ("kenapa harus dilakukan dua kali... GPU tidak 2x kerja") after noticing Product Scan's editor took visibly longer to open than DO Scan's -- decision matched this task's own "consume the stored parse result" option (the other, "skip classification at upload," was rejected: the user explicitly wants Product Scan's editor to open the same way DO's does -- instantly, from already-complete data). - Backend counterpart (see `backend/plans/next-enhancements.md` §11): `api/parse/route.ts`'s Product branch now calls the same shared `classifyAndMatchProduct()` util `POST /api/v1/scan-product` already used (task 9.3), instead of its own poorer inline classify call that only kept `top1_name`/`extracted_sku`. The full result -- top-5 `possibleMatches` and OCR `extractedExpiryDate` -- is now persisted in `documents.metadata.productScan` (JSONB, no migration) and surfaced by `document-mapper.ts` on every GET response. - `lib/models/document_model.dart` gained `productScanMatches`/ `productScanExtractedExpiryDate`, parsed from the new `productScan` key (empty defaults for DO documents or pre-fix Product documents). - `product_editor_data_logic.dart`'s `_fetchClassificationAndSkus()` no longer re-uploads the image to `/scan-product` at all -- it reads `_document.productScanMatches`/`productScanExtractedExpiryDate` synchronously (mirrors `EditorScreen._loadDocumentData()`'s instant local-state read exactly), and only falls back to a *cheap* `GET /master/skus` (a plain DB read, no GPU) when the document has zero stored matches (e.g. a pre-fix document, or nothing scored above the match threshold). `_loading` no longer starts `true` -- no spinner on the happy path. - Tests: `test/document_product_scan_field_test.dart` (3 cases, the new `DocumentModel` fields), `test/product_editor_no_double_classify_test.dart` (proves a document with stored matches renders immediately with no network call -- seeds a raw pending-queue JSON blob with `productScan` already populated and asserts the SKU/confidence UI appears without hitting the sandboxed-test-network 400 path that would fire if a second classify call were attempted). `test/product_editor_classification_failure_test.dart` (pre-existing) continues to pass unchanged -- a `pendingId: null` document has no stored matches, so it now exercises the *fallback* path instead of the old always-on classify path, hitting the same sandboxed 400 and showing the same retry state. Full suite 63/63 pass, `flutter analyze` clean. - Verified live against the running backend: uploaded a genuinely fresh image/store combination as Product Scan -- took 9s (one real GPU classify+match pass, confirmed not a dedup hit via the response's "Document uploaded successfully" message) -- then `GET /documents/:id` immediately returned 5 real `possibleMatches` and the extracted expiry date, before any editor interaction. See docs/iteration-log.md. - **7.3** [DONE 2026-07-10] **Replace magic-string typing and silent mock data** (G4 client half + G7). Split into two independent halves at pickup (per the Grill-Me step — the two halves have different blockers): - **G7 half — DONE 2026-07-10, no backend dependency.** The editor silently fabricated data in three places, all presented as if it were real AI/OCR output: three hardcoded SKU "matches" with fake confidences on any total fetch failure (old `product_editor_logic.dart:116-126`); fake batch dates `'15/12/2026'/'20/04/2027'` whenever OCR extracted no expiry (old line 103); and — found during this pass, same bug class, same screen — `ProductExpiryCard`'s "OCR Confidence Score" was a **literal hardcoded 92.4%**, unconditional, not derived from any real signal at all (there is none - `classify_ocr_server.py`'s OCR result has no confidence field for the expiry extraction). Fixed by: distinguishing "SKU master list itself failed to load" (the one truly-blocking failure, now surfaced as an explicit "Gagal Memuat Klasifikasi Produk" + Coba Lagi retry state, `ProductEditorScreen._buildFailureState()`) from "classification call failed but the master list loaded fine" (now degrades to manual SKU selection from the master list, flagged via new `_hasAutoMatch`, with the fake confidence UI replaced by an honest "Tidak ada rekomendasi otomatis" notice in `ProductDropdownCard`); no expiry match no longer seeds fake dates, instead forcing `_isManualDate = true` (`_updateBatchOptions()`); and `ProductExpiryCard`'s fake 92.4% was replaced with a real, honest label ("Tanggal terdeteksi otomatis dari OCR" vs "Tanggal diinput manual") since there's no real confidence value to show. Tests: `test/product_dropdown_card_test.dart`, `test/product_expiry_card_test.dart`, `test/product_editor_classification_failure_test.dart`. See docs/feature-list.md. - **G4 half — DONE 2026-07-10, unblocked by backend 9.1 shipping (verified against the actual code, not assumed - `backend/pfm-web-app/src/utils/ document-mapper.ts`'s `mapDocumentRow()` now returns `docType`/ `parseStatus`, and all three v1 document routes use it).** Added a real `docType` field to `DocumentModel` (`lib/models/document_model.dart`), read from the backend's `docType` in `fromJson`, falling back to the legacy `orderUntuk == 'PRODUCT SCAN'` sentinel only for responses/cached Hive rows that predate the column (mirrors the backend's own fallback). Replaced every `orderUntuk == 'PRODUCT SCAN'` type check with `docType == 'Product'`: `document_card.dart`, `documents_screen.dart` (tab filter), `pdf_service.dart` (receipt layout), `product_editor_logic.dart` (`_loadDoDocs` PO-candidate filter, and the product-scan `_submit()` now explicitly sets `docType: 'Product'` instead of relying on the `orderUntuk` display string alone). Editing the `orderUntuk` display field can no longer move a document between tabs. Test: `test/document_doctype_test.dart` (3 `fromJson` cases + 1 widget regression test specifically reproducing the old bug's trigger - a doc with `docType: 'Product'` but an edited, non-matching `orderUntuk` still renders as Product). See docs/feature-list.md. - **7.4** [TODO] Stop submitting hardcoded placeholder fields as real data. `_submit()` (`product_editor_submit_logic.dart:76,81`) sends `noSo: '1002003004'` and `platTruk: 'B 1234 PFM'` as literal constants on *every* Product Scan document — these make sense for a real DO but are meaningless here, yet they're persisted server-side as if real. Either omit/null them for `docType: 'Product'` or replace with fields that actually apply to a product scan. - **7.5** [TODO] Fix the store-name fallback that can misattribute data to the wrong store. `currentStoreName` (`product_editor_submit_logic.dart:13`) falls back to the hardcoded literal `'PM KELAPA DUA KARAWACI'` (a real store name) if the cached `nama_toko` preference is ever missing — silently attributing a scan to a different physical store instead of surfacing an error or blocking submission. - **7.6** [TODO] Guard `_submit()` against double-tap. `product_editor_screen.dart:101` wires `onPressed: _submit` directly with no in-flight/loading guard — unlike other async actions in the app, nothing disables the button while a submission (including the re-upload path) is still running, so a fast double-tap can fire two concurrent submissions. ### 8. Flutter — Scan Mode UX & Confirmation Gate `lib/features/camera/scan_mode_provider.dart`, `lib/features/documents/`, `lib/config/app_config.dart`, `lib/models/document_model.dart` Added 2026-07-10 from user testing feedback on the release APK ([`twinkly-riding-mitten.md`](../../../Users/rafha/.claude/plans/twinkly-riding-mitten.md)). Three issues found: (1) `scanModeProvider` and `DocumentsScreen._selectedTab` are independent, causing mode desync across screens; (2) documents appear in history before the user taps "Simpan & Konfirmasi" because `parsed=true` is set at OCR completion not at user confirmation; (3) Product Scan stores fabricated PO/SO/DO placeholder values. Backend §10.1/§10.2 shipped 2026-07-10, unblocking 8.2. See [docs/api-contract-map.md](../docs/api-contract-map.md) **G11**, **G12** for root-cause documentation. §8 is now fully `[DONE]`. - **8.1** [DONE] **Global scan-mode state + DO/Product color cue.** Make `scanModeProvider` the single source of truth bidirectionally: remove `_selectedTab` from `DocumentsScreen` entirely (replace reads with `ref.watch(scanModeProvider)`, replace writes with `ref.read(scanModeProvider.notifier).state = tab`). Formalize the existing ad-hoc `Colors.orange.shade700` in `document_card.dart:28` as `AppConfig.doModeColor`, then apply it as the active-state color in `DocumentsTabSwitcher._buildTabItem` (DO tab) and in the camera drawer's DO segment decoration (was `primaryColor` for both segments). Product stays `AppConfig.primaryColor`. Scope: Flutter-only, no backend dependency. - Tests: (a) widget test that tapping "Product Scan" tab updates `scanModeProvider` (read via `ProviderContainer`, not local widget state); (b) widget test that tab switcher active color = `doModeColor` when `'DO'`, `primaryColor` when `'Product'`; (c) widget test camera drawer DO segment decoration = `doModeColor` when provider = `'DO'`. - **Follow-up (same day)**: user clarified the icon should carry the mode color too, not just the button/text, while explicitly leaving default chrome (tooltips, etc.) untouched. Added `Icons.description`/ `Icons.inventory_2` to `DocumentsTabSwitcher` (matching `CameraDrawerModeToggle`'s existing vocabulary), colored identically to the tab's text. Tests added to `test/scan_mode_color_test.dart` (3 new cases: DO active icon color, Product active icon color, inactive icon stays neutral gray). - **8.2** [DONE 2026-07-10] **Flutter half of confirmation-gated document visibility.** Backend §10.1 shipped first (adds `confirmed` column + filters list to `confirmed = true`). Added optional `bool confirmed` (default `true`) to `DocumentModel`, read from `json['confirmed']` with the same default-true fallback pattern as `docType`/`parseStatus` — so a legacy/cached response without the field behaves as before. No other Flutter changes needed, verified rather than assumed: `mergeDocumentsWithUnsyncedOverrides()` (task 6.2) already keeps `syncFailed` local copies visible even when the server list omits them (which it now legitimately will for unconfirmed docs), and `pending_documents_provider.dart`'s "Tertunda & Diproses" section already shows in-flight items from local state regardless of server confirm status. - Tests: `test/document_confirmed_field_test.dart` (3 cases: reads a real `confirmed: false` from JSON, defaults to `true` when the key is absent, defaults to `true` via the plain constructor too) — same shape as `test/document_doctype_test.dart`. Full suite 58/58 pass, `flutter analyze` clean. Live-verified against the real backend response shape (see backend `docs/iteration-log.md`'s task 10.1/10.2 entry). - **8.3** [TODO] Persist the active scan mode across app restarts. `scanModeProvider` (`scan_mode_provider.dart:3`) is a plain in-memory `StateProvider((ref) => 'DO')` — every cold launch resets to 'DO' regardless of which mode the staff was last using, even for a store role that primarily does Product Scans. - **8.4** [TODO] Add a confirmation step before deleting a pending document. Both the `error` and `syncFailed` bottom sheets in `PendingDocumentCard` (`pending_document_card.dart:76-83,102-109`) wire "Hapus Dokumen" straight to `removeDocument()` with no confirm dialog — unlike the 1.3 logout guard, a single mistap permanently discards a captured photo and any OCR/edit progress with no undo. - **8.5** [TODO] Surface how long a document has been awaiting confirmation. A `success`-status pending item (OCR done, waiting for the user to tap through and confirm) has no staleness/aging indicator — it can sit unconfirmed indefinitely with no on-screen reminder, risking goods being shelved before the DO is ever formally confirmed. ### 9. Flutter — Stocks Menu & DO-to-Stock Flow `lib/features/stock/` (new), `lib/features/editor/editor_logic.dart`, `lib/features/editor/product_editor_*`, `lib/core/router/app_router.dart`, `lib/features/camera/camera_drawer.dart` Added 2026-07-10 from a user-directed, extensively grilled ad-hoc feature request (not an `e`/`enhance` section — see AGENTS.md §7). **Read [docs/stock-feature-plan.md](../docs/stock-feature-plan.md) first** — it is the full design doc (schema, API contracts, screen/file layout, sequencing, testing plan) for this section and for backend counterpart `backend/plans/next-enhancements.md` §12. **Status: planned, not yet implemented** — no code for this feature exists in the codebase yet. - **9.1** [TODO] **Stock data layer.** New `lib/models/stock_model.dart` (`StockBatch`/`StockItem`), `lib/features/stock/stock_response_parser.dart`, `stock_batch_sort.dart`, `quantity_parse.dart` (pure functions, unit tested), `stock_provider.dart` (`StateNotifier` per `pending_documents_provider.dart` convention), new `stockEndpoint` in `app_config.dart`, new `stockEntryBoxName` Hive box in `local_storage.dart`. Blocked on backend §12.1 shipping the `/api/v1/stock/*` routes this layer calls. - **9.2** [TODO] **Stock-entry screen + DO-confirm trigger.** New `lib/features/stock/stock_entry_screen.dart` (+ `stock_entry_logic.dart`/ `stock_entry_submit_logic.dart` part files, widgets) — the mandatory-by-default screen auto-navigated to right after a DO's `PUT /api/v1/documents/:id` succeeds (`editor_logic.dart`'s `_submitDocument()`), pre-filling each item with 1 batch at its confirmed quantity, "+ Tambah Batch" to split, "Isi Nanti" to defer (Hive-persisted, resumable). New pure `lib/features/editor/document_submit_navigation.dart` (mirrors `resolveDocumentSaveAction`/`resolvePollOutcome`) so `editor_logic.dart` stays under the 256-line threshold. New `/stock-entry` route in `app_router.dart`. Blocked on 9.1. - **9.3** [TODO] **Stocks menu (list/detail) + drawer entry.** New `lib/features/stock/stocks_screen.dart` (searchable SKU list + "Perlu diisi" resumable-draft banner) and `stock_detail_screen.dart` (batch list, soonest-expiry-first, red "Kadaluwarsa" flag, manual add/edit batch actions). New "Stok" item in `camera_drawer.dart`, `/stocks`/`/stock-detail` routes in `app_router.dart`. Blocked on 9.1. - **9.4** [TODO] **Product Scan rewrite onto real stock batches.** Replace `product_editor_data_logic.dart`'s fake `_skuBatches` (a single OCR-echoed date string) with real per-store batch data from 9.1's provider; SKU candidates restricted to in-stock SKUs (backend §12.2 filter); closest-to- OCR-expiry batch auto-selected; depleted batches shown with a warning, not hidden. `product_editor_submit_logic.dart` sends the selected batch id (`stock_batch_id`) on PUT so backend §12.2's decrement hook fires. `product_expiry_card.dart` prop-shape rewrite (breaking change — update `test/product_expiry_card_test.dart`, don't leave it stale). New `selectedStockBatchId` field on `DocumentModel`. Blocked on 9.1 and backend §12.2. - **9.5** [TODO] Define draft-recovery behavior for a killed app mid-stock-entry. The plan covers explicit "Isi Nanti" (finish-later) resumability, but not what happens if the app is killed *while* the stock-entry form is open, before that button is tapped — the same class of loss already fixed for the pending-upload queue (task 3.1). Specify whether the form autosaves per-field to Hive or only on an explicit exit action, so typed batch/quantity data isn't silently lost. - **9.6** [TODO] Resolve the SKU master staleness risk before it hits stock intake. Since the DO editor currently validates SKUs against a static bundled `lib/data/master_sku.dart` map instead of the live `/api/v1/master/skus` endpoint (task 4.5), a SKU that exists in the backend's `sku_master` table but not yet in the bundled copy would fail client-side validation during DO entry — blocking that item from ever reaching stock intake even though the backend would accept it. Fix 4.5 before or alongside 9.1. - **9.7** [TODO] Require a reason for manual, DO-unlinked stock adjustments. `stock_movements.note` (`init-stock.ts`'s schema) is an optional, nullable `VARCHAR(500)` — DO-triggered movements are always tied to a real `document_id`, but a manual "Tambah Batch" (`manual_seed`/`adjustment`, no DO link) can be submitted with no justification at all, leaving no mandatory audit trail for a fabricated or corrected stock quantity. *Suggested order: 9.1 → 9.2/9.3 (independent of each other once 9.1 lands) → 9.4 (needs backend §12.2's decrement hook, not just §12.1's CRUD). 9.5-9.7 are design-completeness fixes to fold into 9.1-9.4's implementation, not a separate pass.* ## 10. Cashier Fast Path — Automated Expiry Resolution `lib/features/editor/` (product editor), `lib/features/documents/`, `lib/features/camera/` Added 2026-07-16 from a user-directed grilling session (ad-hoc feature per `AGENTS.md` §7). **Read [docs/expiry-tracking-plan.md](../docs/expiry-tracking-plan.md) first** — full design and confirmed decisions (checkout must be fully automated: zero typing, zero blocking prompts; unresolvable scans auto-record the FEFO batch with an `inferred` flag). Consumes backend §13's resolution (`resolvedBatch`/`source`/`score` inside `productScan`). **Blocked on 9.1–9.4 and backend §13.2.** - **10.1** [TODO] **One-tap (or zero-tap) confirm at the cashier.** The Product Scan editor pre-selects backend §13's resolved batch; when `source` is `single_batch`/`matched_exact`/`matched_fragment`, the flow collapses to a single confirm tap with the resolved expiry shown prominently — grill at pickup whether to go full auto-confirm (no tap) for high-confidence resolutions. The §9.4 batch dropdown remains as the manual-override path only (override ⇒ backend records `expiry_source = 'manual'`). - **10.2** [TODO] **`inferred` badge + end-of-day review list.** Sales resolved as `inferred_fefo` show a small amber "perkiraan" badge in the editor and in the history/documents list — informational only, never a blocking prompt (confirmed decision). New review entry point (drawer or History filter) listing flagged sales via backend §13.2's `?expiry_source=inferred_fefo` filter, so staff can optionally correct them after hours with the packs in hand — zero checkout impact by design. - **10.3** [TODO] **Phase 2 — burst capture mode for a mounted camera.** Camera layer gains a burst mode (N frames over ~1s) intended for a fixed-mounted phone at the checkout counter; frames upload together and backend §13.3 unions OCR evidence across them. Includes a settings toggle (hand-held single-shot vs mounted burst). Blocked on backend §13.3. *Suggested order: 10.1 → 10.2 (10.1's `source` plumbing feeds 10.2's badge); 10.3 only after Phase-1 field measurement says fragment quality is the bottleneck.* --- *Sections 1-10 (Flutter) are the only sections this file tracks. Backend enhancements (formerly sections 5-8 here, removed 2026-07-08) now live exclusively in [backend/plans/next-enhancements.md](../backend/plans/next-enhancements.md); that file's §9 holds the backend counterparts to this file's §6-7, §10 holds the backend counterparts to this file's §8, §12 holds the backend counterparts to this file's §9, and §13 holds the backend counterparts to this file's §10 (see [docs/api-contract-map.md](../docs/api-contract-map.md), [docs/stock-feature-plan.md](../docs/stock-feature-plan.md), and [docs/expiry-tracking-plan.md](../docs/expiry-tracking-plan.md) for the shared design docs).*