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>
Review hardening for Task 8 (test-server workflow: testers install older
APKs over a newer DB):
- Old APK opening a v2 database hits drift's downgrade path, which leaves
the force column in place but writes user_version back to 1. Reinstalling
the new APK then re-runs onUpgrade(1,2) and addColumn crashes with
"duplicate column name: force", failing the DB open.
- onUpgrade now checks PRAGMA table_info(upload_tasks) and only adds the
force column when absent; the normal v1 -> v2 path is unchanged.
- Migration test simulates the downgrade-reupgrade cycle on a file-backed
database (v2 open -> user_version forced back to 1 -> re-upgrade),
asserting the open succeeds, legacy rows survive, and the column stays
writable. RED before the guard (SqliteException: duplicate column name).
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>
- MainActivity locked to portrait: the capture page top bar / bottom
controls and the preview aspect swap all assume portrait; landscape
misaligns the preview ratio and control layers.
- Zoom badge now requires details.pointerCount >= 2 on onScaleStart so a
single stationary tap no longer pops a 1.0x badge for 1s; base-zoom
rebasing stays unconditional (single-finger-into-pinch still resumes
from the current level).
- Removed unused CameraPluginSource.currentZoom getter.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Preview now centers at the sensor aspect ratio (letterboxed on the dark
backdrop) instead of FittedBox cover, which cropped ~38% horizontally and
made the preview differ from the full-frame takePicture output.
Zoom capability hangs off CaptureSource as default implementations
(minZoom/maxZoom = 1, setZoom no-op) so existing fakes stay untouched;
CameraPluginSource probes getMin/MaxZoomLevel after initialize (failure
keeps 1/1 and disables the gesture). The page attaches a scale
GestureDetector only when maxZoom > 1: pinch drives setZoom (throttled at
0.05 delta, serialized to prevent out-of-order platform calls), a badge
shows the current level and hides after 1s, double-tap resets to 1.0x,
and camera flip/reopen resets zoom state. _StaticCaptureSource in
album_detail_page_test.dart switches implements -> extends to inherit the
new defaults.
Co-Authored-By: Claude Code <noreply@anthropic.com>
review fixes: drop GIF from pre-sniff (package maps gif→png decode → never-ready hang); PopScope blocks back during processing
Co-Authored-By: Claude Code <noreply@anthropic.com>