/// 选图注入点 + 批量入队动作(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 匹配 → 返回 [DuplicateCheck],由 /// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。 /// /// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传—— /// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行 /// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin /// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。 library; import 'dart:io'; import 'package:flutter/widgets.dart' show BuildContext, Localizations; 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/notification_permission.dart'; import 'providers.dart'; import 'task_state.dart'; /// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。 typedef SingleImagePicker = Future Function(); /// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。 typedef MultiImagePicker = Future> Function(BuildContext context); /// 单图选择 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 仅发生在用户取消。 final multiImagePickerProvider = Provider( (ref) => (context) async { final locale = Localizations.maybeLocaleOf(context); final assets = await AssetPicker.pickAssets( context, pickerConfig: AssetPickerConfig( requestType: RequestType.image, maxAssets: kPickerSoftMaxAssets, textDelegate: assetPickerTextDelegateFromLocale(locale), ), ); 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; /// 查重弹窗选择结果(Task 8):null=取消(不上传)。 /// /// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→ /// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。 typedef DuplicateDialogChoice = ({Set skipPaths, Set forcePaths}); /// 查重结果(单张):匹配到的已上传任务摘要。 class DuplicateMatch { const DuplicateMatch({ required this.filename, required this.bytes, required this.uploadedAt, }); 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; } /// 入队前查重:查 TaskStore 同账号 + 同相册 + state=uploaded, /// filename + bytes 匹配的记录。 /// /// 返回 null = 无重复(可直接入队);非 null = 有疑似重复(调用方弹窗)。 Future?> checkDuplicates( WidgetRef ref, { required String accountId, required String albumId, required List sourcePaths, }) async { if (sourcePaths.isEmpty) return null; // 收集本次选择的 filename + bytes。 final picks = <({String filename, int bytes})>[]; for (final path in sourcePaths) { try { final file = File(path); picks.add((filename: p.basename(path), bytes: await file.length())); } on Object { // 读不到的文件不参与查重(入队时准备器会报错)。 } } if (picks.isEmpty) return null; // 查同相册已完成的任务。 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( filename: pick.filename, bytes: pick.bytes, uploadedAt: task.createdAt, ), ); break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。 } } } return matches.isEmpty ? null : matches; } /// 批量入队:通知权限单次请求 + 逐张准备入队。 /// /// [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, ); }