/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。 /// /// 选图的平台调用经 Provider 注入([singleImagePickerProvider]=image_picker /// 单图;[multiImagePickerProvider]=wechat_assets_picker 应用内多选), /// widget 测试 override 为替身——不经平台通道即可驱动入队流; /// [enqueueForUpload] 为连拍页与相册详情 FAB 的共用入队路径:首次动作 /// 触发 POST_NOTIFICATIONS 请求(静默降级)+ 批量经准备器入队(准备器 /// 内部有界并行复制,默认并发 3,防连拍 IO 风暴)。 /// /// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 + /// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateMatch],由 /// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。 /// 本地匹配经统一入口 [checkDuplicatesUnified] 驱动(原独立入口 /// checkDuplicates 已折叠为私有半场,仓库内无其他调用方)。 /// /// Task 2(查重前置到入队前·统一入口):入队前查重升级为 /// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史 /// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级 /// 本机-only,不阻塞入队。 /// /// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传—— /// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行 /// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin /// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。 /// /// 方案 A(选图点选拦截):[multiImagePickerProvider] 经 /// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选 /// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张 /// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前 /// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409 /// 兜底不变)。 library; import 'dart:io'; import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar; import 'package:flutter/widgets.dart' show BuildContext, Localizations, Text; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as p; import 'package:wechat_assets_picker/wechat_assets_picker.dart'; import '../../core/api_client.dart' show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit, apiClientProvider; import '../../core/notification_permission.dart'; import '../../l10n/app_localizations.dart'; import 'providers.dart'; import 'task_state.dart'; import 'task_store.dart'; /// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。 typedef SingleImagePicker = Future Function(); /// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。 /// /// [accountId]/[albumId] 为点选拦截的判定范围(方案 A:当前目标相册; /// 与入队目标同源)——两处调用点(相册详情/连拍页)均持有,必传。 typedef MultiImagePicker = Future> Function( BuildContext context, { required String accountId, required String albumId, }); /// 单图选择 Provider(生产=image_picker;测试 override)。 final singleImagePickerProvider = Provider( (ref) => () async { final file = await ImagePicker().pickImage(source: ImageSource.gallery); return file?.path; }, ); /// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。 /// /// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker /// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页 /// 加载,实际不受限); /// - originFile 取原始文件路径:API 30+ 直接返回原路径;Android 10 非 /// legacy 经 ScopedCache 复制到应用缓存(较慢、占缓存空间)。路径 /// 解析并行发起(批量数百张时避免串行平台通道往返阻塞入队启动; /// 平台通道调用在平台线程自然串行化,无 IO 风暴); /// - 单个资产解析失败(选择后被删/云下架等 PlatformException)仅跳过 /// 该张(null 过滤),不丢弃整批;入队数由调用方 SnackBar 反映实际 /// 张数; /// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图 /// (非「返回空」);返回 null 仅发生在用户取消; /// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已 /// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。 final multiImagePickerProvider = Provider( (ref) => (context, {required accountId, required albumId}) async { // 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。 final gate = PickerDuplicateGate( store: ref.read(taskStoreProvider), client: ref.read(apiClientProvider), accountId: accountId, albumId: albumId, ); final locale = Localizations.maybeLocaleOf(context); final assets = await AssetPicker.pickAssets( context, pickerConfig: AssetPickerConfig( requestType: RequestType.image, maxAssets: kPickerSoftMaxAssets, textDelegate: assetPickerTextDelegateFromLocale(locale), // 点选拦截(selectPredicate 签名 FutureOr Function(ctx, // asset, selected)——false 静默不选);AssetEntity → // [PickerAssetFact] 适配(fileSize 惰性求值)在此完成。 selectPredicate: (ctx, asset, selected) => gate.decideFact( ctx, (id: asset.id, title: asset.title, fileSize: () => asset.fileSize), selected, ), ), ); if (assets == null) return const []; final files = await Future.wait( assets.map((a) => a.originFile.then((f) => f, onError: (_) => null)), ); return [ for (final file in files) if (file != null) file.path, ]; }, ); /// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。 const int kPickerSoftMaxAssets = 9999; /// 拦截器所需的最小资产事实([PickerDuplicateGate] 的测试接缝)。 /// /// AssetEntity → 此记录的适配在 [multiImagePickerProvider] 内完成: /// - `fileSize`:photo_manager 3.12 为 `Future get fileSize` /// (entity.dart:778——平台通道异步,无同步 length 字段);以惰性闭包 /// 传入,缓存命中/取消选择/二次点击路径均不发起查询; /// - `title`:`String?`——iOS 需 FilterOption.needTitle 才非空 /// (wechat_assets_picker 默认 filterOptions 仅对 audio 开),Android /// 侧无条件携带(ConvertUtils.kt 无条件 `title to entity.displayName`); /// null → 本机半场跳过(服务器按 bytes 仍可判)。 typedef PickerAssetFact = ({ String id, String? title, Future Function() fileSize, }); /// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的 /// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码 /// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回 /// false 静默不选)。 /// /// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器 /// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按 /// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化 /// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。 /// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选 /// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟 /// 考量;与批量 union 不短路不同)。 /// /// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`, /// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold /// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条), /// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择 /// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张 /// 重复查询。 /// /// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。 class PickerDuplicateGate { PickerDuplicateGate({ required this.store, required this.client, required this.accountId, required this.albumId, this.serverCheckTimeout = const Duration(seconds: 2), }); /// 本机历史半场数据源(生产=taskStoreProvider)。 final TaskStore store; /// 服务器单项预检半场客户端(生产=apiClientProvider)。 final ApiClient client; /// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。 final String accountId; /// 判定范围:目标相册(与入队目标一致)。 final String albumId; /// 服务器半场预检时间帽(默认 2s;可注入短值做测试)。超时=无命中, /// 同各半场失败降级放行;底层 dio 请求不取消(其自身超时照常兜底)。 final Duration serverCheckTimeout; /// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。 final Map _verdicts = {}; /// 二次点击放行集(首次拦截时记入——escape hatch)。 final Set _forced = {}; /// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。 /// /// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。 Future decideFact( BuildContext ctx, PickerAssetFact asset, bool selected, ) async { if (selected) return true; if (_forced.contains(asset.id)) return true; // 二次点击:强制选择。 final isDuplicate = _verdicts[asset.id] ?? await _judge(asset); _verdicts[asset.id] = isDuplicate; if (!isDuplicate) return true; _forced.add(asset.id); // 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false, // 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。 if (ctx.mounted) _notifyBlocked(ctx); return false; } /// 判定一张是否重复(true=重复):本机 ∪ 服务器,异常降级放行。 Future _judge(PickerAssetFact asset) async { try { // 文件大小:两半场共用,取一次。 final bytes = await asset.fileSize(); // 层 1:本机历史(同账号+相册+completed,filename+bytes)。 final title = asset.title; if (title != null) { try { final completed = await store.listByAccount( accountId, albumId: albumId, states: const {UploadTaskState.completed}, ); final hit = completed.any( (t) => t.filename == title && t.bytes == bytes, ); if (hit) return true; // 本机命中短路服务器(点选延迟考量)。 } on Object { // 本机查询失败:该半场按无命中,服务器半场仍判。 } } // 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes; // title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中 // ——弱网下点选不静默挂到 dio 连接超时(10s)。 try { final hits = await client .checkUploadDuplicates( albumId, [(filename: title ?? '', bytes: bytes)], ) .timeout( serverCheckTimeout, onTimeout: () => const [], ); return hits.isNotEmpty; } on Object { // 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。 return false; } } on Object { // fileSize 失败等任意异常:放行。 return false; } } /// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。呈现前清队列:快速 /// 点多个重复资产时只留当前一条,不排队 N 条 4s 提示。 void _notifyBlocked(BuildContext ctx) { ScaffoldMessenger.of(ctx) ..clearSnackBars() ..showSnackBar( SnackBar( content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked), ), ); } } /// 查重弹窗选择结果(Task 8):null=取消(不上传)。 /// /// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→ /// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。 typedef DuplicateDialogChoice = ({Set skipPaths, Set forcePaths}); /// 查重结果(单张):匹配到的已上传任务摘要。 class DuplicateMatch { const DuplicateMatch({ required this.sourcePath, required this.filename, required this.bytes, required this.uploadedAt, }); /// 本批照片的完整源路径:弹窗/入队按路径精确派生重复项——同 basename /// 不同目录/扩展名的兄弟文件不误伤(Task 2 评审 #2)。 final String sourcePath; final String filename; final int bytes; final DateTime uploadedAt; } /// 入队结果(P0-1:含查重信息)。 class EnqueueResult { const EnqueueResult({ required this.enqueued, required this.duplicates, this.skipped = 0, }); /// 成功入队张数(state=queued)。 final int enqueued; /// 与历史已上传任务匹配的项(需用户确认)。 final List duplicates; /// 用户选择跳过的重复项张数。 final int skipped; } /// 本批照片的 stat 结果(路径 + 派生 filename + 字节数;层 1/层 2 查重 /// 共用——文件只 stat 一次,Task 2 评审 #3)。 typedef _PickStat = ({String path, String filename, int bytes}); /// stat 本批文件([checkDuplicatesUnified] 专用;读不到的文件跳过, /// 入队时准备器会报错)。 Future> _statPicks(List sourcePaths) async { final picks = <_PickStat>[]; for (final path in sourcePaths) { try { final file = File(path); picks.add(( path: path, filename: p.basename(path), bytes: await file.length(), )); } on Object { // 读不到的文件不参与查重(入队时准备器会报错)。 } } return picks; } /// 层 1 本机匹配:TaskStore 同账号 + 同相册 + state=completed 的任务与 /// 本批 stat 结果做 filename + bytes 匹配(原公共 checkDuplicates 的 /// 内核,折叠为统一入口的私有半场——仓库内已无其他调用方)。 Future> _matchLocalHistory( WidgetRef ref, { required String accountId, required String albumId, required List<_PickStat> picks, }) async { // 查同相册已完成的任务。 final store = ref.read(taskStoreProvider); final completed = await store.listByAccount( accountId, albumId: albumId, states: {UploadTaskState.completed}, ); // 匹配 filename + bytes。 final matches = []; for (final task in completed) { for (final pick in picks) { if (task.filename == pick.filename && task.bytes == pick.bytes) { matches.add( DuplicateMatch( sourcePath: pick.path, filename: pick.filename, bytes: pick.bytes, uploadedAt: task.createdAt, ), ); break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。 } } } return matches; } /// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪ /// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes—— /// filename 刻意不参与,与 initUpload 查重一致)。 /// /// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞 /// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。 /// /// 本批文件只 stat 一次([_statPicks],两层共用);服务器命中按全局 /// 下标映射回 stat 结果(filename/sourcePath 由此生效);合并按 /// sourcePath 去重——同一文件双命中只呈现一条,本机命中在前(本地 /// uploadedAt 优先,Task 2 评审 #1)。 Future?> checkDuplicatesUnified( WidgetRef ref, { required String accountId, required String albumId, required List sourcePaths, }) async { if (sourcePaths.isEmpty) return null; // stat 一次,层 1/层 2 共用。 final picks = await _statPicks(sourcePaths); if (picks.isEmpty) return null; // 层 1:本机 TaskStore 历史(filename+bytes 匹配)。 final local = await _matchLocalHistory( ref, accountId: accountId, albumId: albumId, picks: picks, ); // 层 2:服务器历史(bytes 命中,逐请求条目一条;失败静默降级)。 final serverHits = []; try { serverHits.addAll( await ref .read(apiClientProvider) .checkUploadDuplicates( albumId, [ for (final pick in picks) (filename: pick.filename, bytes: pick.bytes), ], ), ); } on Object { // 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队 //(服务端 init 409 仍是兜底)。 } // 合并去重(sourcePath 唯一标识一张本批照片;putIfAbsent 保留先见 // 者——本机在前,故同一重复的 uploadedAt 取本地历史)。 final byPath = {}; for (final match in [ ...local, for (final hit in serverHits) if (hit.index >= 0 && hit.index < picks.length) DuplicateMatch( sourcePath: picks[hit.index].path, filename: picks[hit.index].filename, bytes: hit.bytes, uploadedAt: hit.uploadedAt, ), ]) { byPath.putIfAbsent(match.sourcePath, () => match); } return byPath.isEmpty ? null : byPath.values.toList(); } /// 批量入队:通知权限单次请求 + 逐张准备入队。 /// /// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」 /// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload /// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。 /// /// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。 /// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见, /// 不在此抛出)。 Future enqueueForUpload( WidgetRef ref, { required String accountId, required String albumId, String? categoryId, required List sourcePaths, List? skipPaths, List? forcePaths, }) async { // 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。 await ref.read(notificationPermissionProvider).ensureRequested(); final preparer = ref.read(uploadPreparerProvider); // P0-1:跳过用户选择取消的重复项。 final skipSet = skipPaths?.toSet() ?? {}; final effective = sourcePaths .where((path) => !skipSet.contains(path)) .toList(); // Task 8:「全部上传」确认的强传项(重复项 force=true 落任务行)。 final forceSet = forcePaths?.toSet() ?? {}; final results = await Future.wait( effective.map((path) => preparer.prepare( accountId: accountId, albumId: albumId, categoryId: categoryId, sourcePath: path, force: forceSet.contains(path), )), ); final enqueued = results .where((task) => task.state == UploadTaskState.queued) .length; return EnqueueResult( enqueued: enqueued, duplicates: [], skipped: sourcePaths.length - effective.length, ); }