Review follow-ups on 0fa23e0 (Task 2), applied as one commit:
- Path-precise dupPaths (#2, user-visible): DuplicateMatch gains
sourcePath (local match fills it from the matched pick; unified
server branch from picks[hit.index].path). Both pages' duplicate
dialogs derive dupPaths from duplicates' sourcePath instead of
basename matching, so same-basename siblings (different dir/bytes)
are no longer mis-flagged — "跳过重复" no longer drops the
non-duplicate sibling. Stale basename-derivation comment removed.
- Single shared stat (#3): files are stat'd once in
checkDuplicatesUnified (_statPicks) and the picks feed both the
local half (extracted _matchLocalHistory; public checkDuplicates
folded — it had no remaining callers) and the server request.
Halves stat IO for large batches (was ~2x).
- Union test (#1): new test/features/uploads/enqueue_test.dart pins
that a file hit by BOTH local and server history yields ONE
DuplicateMatch with the local uploadedAt (server's echoed time does
not override). Merge dedup is now keyed by sourcePath
(putIfAbsent, local first).
Tests: +1 enqueue union test (real-async chain inside one runAsync
window; sync file/dir setup — fake-async body must not await real
IO), +1 album_detail same-basename sibling test (skip-duplicates
enqueues exactly the non-hit sibling, distinguished by bytes since
sourceFilePath becomes the preparer's staging copy).
Gates: flutter analyze clean; full flutter test 429/429 green.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.
- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
(commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
index translated to global; empty list short-circuits (no request);
errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
history; server hits map back to filename via global index into the
stat'd picks; merged, de-duplicated (filename+bytes) list; server
failure (network/permission/any) silently degrades to local-only —
init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
unified entry; dialog/skip/force logic untouched.
Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.
- ApiClient.mapDioException: normalize 409 wire body reusing
VALIDATION_FAILED with message=PHOTO_DUPLICATE to
ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
bucket (retrying the same init body would 409 again); force to skip
dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
+ album detail page) now returns DuplicateDialogChoice so confirmed
duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)
Co-Authored-By: Claude Code <noreply@anthropic.com>
Parallelize the enqueueForUpload collect loop (preparer's internal
bounded semaphore from the previous task keeps IO in check). Also two
test hardenings from that task's code review: pin that failed and
thrown prepare operations release their concurrency slot (cap=1,
subsequent prepare still completes), and pin the maxConcurrentPrepares
<1 -> 1 clamp (peak 1, no deadlock).
Co-Authored-By: Claude Code <noreply@anthropic.com>
Task 9 (P3 plan): queue tab replaces the stub, continuous capture page
enqueues per shot, album detail gains an upload FAB, and the upload
engine lifecycle is now bound to the login session.
- Engine scope: session account drives UploadEngine start/stop; wired
from app boot, login success, logout, and 401 eviction. Tasks stay in
the store across logout (accountId hard isolation).
- Queue tab: album x state x time filter bar, task rows with thumbnail,
state chip, localized error copy, attempt count, per-row progress via
a broadcast progress hub, state-dependent actions (re-pick retry,
retryNow, cancel, delete), batch retry bar, 200ms-debounced live
refresh from drift table updates.
- Capture page: camera preview via injected CaptureSource, one enqueue
per shutter press, persistent album/category/badges overlay with a
switch-only-affects-new-shots hint, gallery multi-pick, exit confirm
while uploads are pending, permission-denied and init-failure views.
- POST_NOTIFICATIONS runtime permission via a small MethodChannel
(photolive/permissions) + PermissionPlugin.kt: single runtime
permission need, avoids permission_handler dependency; requested once
on first enqueue, silent degradation when denied or unavailable.
- Album detail FAB: capture-and-upload / pick-from-gallery sheet.
- l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine
scope lifecycle, permission gate, queue tab, capture page, FAB entry);
existing page tests updated to inject session/task-store fakes.
Co-Authored-By: Claude Code <noreply@anthropic.com>