2026-09-19 04:47:31 +08:00
|
|
|
|
/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。
|
|
|
|
|
|
///
|
2026-09-28 14:43:59 +08:00
|
|
|
|
/// 选图的平台调用经 Provider 注入([singleImagePickerProvider]=image_picker
|
|
|
|
|
|
/// 单图;[multiImagePickerProvider]=wechat_assets_picker 应用内多选),
|
|
|
|
|
|
/// widget 测试 override 为替身——不经平台通道即可驱动入队流;
|
|
|
|
|
|
/// [enqueueForUpload] 为连拍页与相册详情 FAB 的共用入队路径:首次动作
|
|
|
|
|
|
/// 触发 POST_NOTIFICATIONS 请求(静默降级)+ 批量经准备器入队(准备器
|
|
|
|
|
|
/// 内部有界并行复制,默认并发 3,防连拍 IO 风暴)。
|
2026-09-28 11:06:40 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateMatch],由
|
2026-09-28 11:06:40 +08:00
|
|
|
|
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// 本地匹配经统一入口 [checkDuplicatesUnified] 驱动(原独立入口
|
|
|
|
|
|
/// checkDuplicates 已折叠为私有半场,仓库内无其他调用方)。
|
2026-09-28 17:06:19 +08:00
|
|
|
|
///
|
2026-09-28 19:17:35 +08:00
|
|
|
|
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
|
|
|
|
|
|
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
|
|
|
|
|
|
/// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级
|
|
|
|
|
|
/// 本机-only,不阻塞入队。
|
|
|
|
|
|
///
|
2026-09-28 17:06:19 +08:00
|
|
|
|
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
|
|
|
|
|
|
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
|
|
|
|
|
|
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
|
|
|
|
|
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
|
2026-09-19 04:47:31 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
2026-09-28 11:06:40 +08:00
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
2026-09-28 14:43:59 +08:00
|
|
|
|
import 'package:flutter/widgets.dart' show BuildContext, Localizations;
|
2026-09-19 04:47:31 +08:00
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
import 'package:image_picker/image_picker.dart';
|
2026-09-28 11:06:40 +08:00
|
|
|
|
import 'package:path/path.dart' as p;
|
2026-09-28 14:43:59 +08:00
|
|
|
|
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
2026-09-19 04:47:31 +08:00
|
|
|
|
|
2026-09-28 19:17:35 +08:00
|
|
|
|
import '../../core/api_client.dart'
|
|
|
|
|
|
show UploadDuplicateHit, apiClientProvider;
|
2026-09-19 04:47:31 +08:00
|
|
|
|
import '../../core/notification_permission.dart';
|
|
|
|
|
|
import 'providers.dart';
|
|
|
|
|
|
import 'task_state.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
|
|
|
|
|
|
typedef SingleImagePicker = Future<String?> Function();
|
|
|
|
|
|
|
|
|
|
|
|
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
|
2026-09-28 14:43:59 +08:00
|
|
|
|
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
|
2026-09-19 04:47:31 +08:00
|
|
|
|
|
|
|
|
|
|
/// 单图选择 Provider(生产=image_picker;测试 override)。
|
|
|
|
|
|
final singleImagePickerProvider = Provider<SingleImagePicker>(
|
|
|
|
|
|
(ref) => () async {
|
|
|
|
|
|
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
|
|
|
|
|
|
return file?.path;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-28 14:43:59 +08:00
|
|
|
|
/// 多图选择 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 仅发生在用户取消。
|
2026-09-19 04:47:31 +08:00
|
|
|
|
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
2026-09-28 14:43:59 +08:00
|
|
|
|
(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 <String>[];
|
|
|
|
|
|
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,
|
|
|
|
|
|
];
|
2026-09-19 04:47:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-28 14:43:59 +08:00
|
|
|
|
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
|
|
|
|
|
|
const int kPickerSoftMaxAssets = 9999;
|
|
|
|
|
|
|
2026-09-28 17:06:19 +08:00
|
|
|
|
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
|
|
|
|
|
|
/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。
|
|
|
|
|
|
typedef DuplicateDialogChoice
|
|
|
|
|
|
= ({Set<String> skipPaths, Set<String> forcePaths});
|
|
|
|
|
|
|
2026-09-28 11:06:40 +08:00
|
|
|
|
/// 查重结果(单张):匹配到的已上传任务摘要。
|
|
|
|
|
|
class DuplicateMatch {
|
|
|
|
|
|
const DuplicateMatch({
|
2026-09-28 20:22:37 +08:00
|
|
|
|
required this.sourcePath,
|
2026-09-28 11:06:40 +08:00
|
|
|
|
required this.filename,
|
|
|
|
|
|
required this.bytes,
|
|
|
|
|
|
required this.uploadedAt,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// 本批照片的完整源路径:弹窗/入队按路径精确派生重复项——同 basename
|
|
|
|
|
|
/// 不同目录/扩展名的兄弟文件不误伤(Task 2 评审 #2)。
|
|
|
|
|
|
final String sourcePath;
|
2026-09-28 11:06:40 +08:00
|
|
|
|
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<DuplicateMatch> duplicates;
|
|
|
|
|
|
|
|
|
|
|
|
/// 用户选择跳过的重复项张数。
|
|
|
|
|
|
final int skipped;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// 本批照片的 stat 结果(路径 + 派生 filename + 字节数;层 1/层 2 查重
|
|
|
|
|
|
/// 共用——文件只 stat 一次,Task 2 评审 #3)。
|
|
|
|
|
|
typedef _PickStat = ({String path, String filename, int bytes});
|
2026-09-28 11:06:40 +08:00
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// stat 本批文件([checkDuplicatesUnified] 专用;读不到的文件跳过,
|
|
|
|
|
|
/// 入队时准备器会报错)。
|
|
|
|
|
|
Future<List<_PickStat>> _statPicks(List<String> sourcePaths) async {
|
|
|
|
|
|
final picks = <_PickStat>[];
|
2026-09-28 11:06:40 +08:00
|
|
|
|
for (final path in sourcePaths) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final file = File(path);
|
2026-09-28 20:22:37 +08:00
|
|
|
|
picks.add((
|
|
|
|
|
|
path: path,
|
|
|
|
|
|
filename: p.basename(path),
|
|
|
|
|
|
bytes: await file.length(),
|
|
|
|
|
|
));
|
2026-09-28 11:06:40 +08:00
|
|
|
|
} on Object {
|
|
|
|
|
|
// 读不到的文件不参与查重(入队时准备器会报错)。
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-28 20:22:37 +08:00
|
|
|
|
return picks;
|
|
|
|
|
|
}
|
2026-09-28 11:06:40 +08:00
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// 层 1 本机匹配:TaskStore 同账号 + 同相册 + state=completed 的任务与
|
|
|
|
|
|
/// 本批 stat 结果做 filename + bytes 匹配(原公共 checkDuplicates 的
|
|
|
|
|
|
/// 内核,折叠为统一入口的私有半场——仓库内已无其他调用方)。
|
|
|
|
|
|
Future<List<DuplicateMatch>> _matchLocalHistory(
|
|
|
|
|
|
WidgetRef ref, {
|
|
|
|
|
|
required String accountId,
|
|
|
|
|
|
required String albumId,
|
|
|
|
|
|
required List<_PickStat> picks,
|
|
|
|
|
|
}) async {
|
2026-09-28 11:06:40 +08:00
|
|
|
|
// 查同相册已完成的任务。
|
|
|
|
|
|
final store = ref.read(taskStoreProvider);
|
|
|
|
|
|
final completed = await store.listByAccount(
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
albumId: albumId,
|
|
|
|
|
|
states: {UploadTaskState.completed},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 匹配 filename + bytes。
|
|
|
|
|
|
final matches = <DuplicateMatch>[];
|
|
|
|
|
|
for (final task in completed) {
|
|
|
|
|
|
for (final pick in picks) {
|
|
|
|
|
|
if (task.filename == pick.filename && task.bytes == pick.bytes) {
|
|
|
|
|
|
matches.add(
|
|
|
|
|
|
DuplicateMatch(
|
2026-09-28 20:22:37 +08:00
|
|
|
|
sourcePath: pick.path,
|
2026-09-28 11:06:40 +08:00
|
|
|
|
filename: pick.filename,
|
|
|
|
|
|
bytes: pick.bytes,
|
|
|
|
|
|
uploadedAt: task.createdAt,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-28 20:22:37 +08:00
|
|
|
|
return matches;
|
2026-09-28 11:06:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 19:17:35 +08:00
|
|
|
|
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
|
|
|
|
|
|
/// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes——
|
|
|
|
|
|
/// filename 刻意不参与,与 initUpload 查重一致)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
|
|
|
|
|
|
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
|
|
|
|
|
|
///
|
2026-09-28 20:22:37 +08:00
|
|
|
|
/// 本批文件只 stat 一次([_statPicks],两层共用);服务器命中按全局
|
|
|
|
|
|
/// 下标映射回 stat 结果(filename/sourcePath 由此生效);合并按
|
|
|
|
|
|
/// sourcePath 去重——同一文件双命中只呈现一条,本机命中在前(本地
|
|
|
|
|
|
/// uploadedAt 优先,Task 2 评审 #1)。
|
2026-09-28 19:17:35 +08:00
|
|
|
|
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
|
|
|
|
|
|
WidgetRef ref, {
|
|
|
|
|
|
required String accountId,
|
|
|
|
|
|
required String albumId,
|
|
|
|
|
|
required List<String> sourcePaths,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
if (sourcePaths.isEmpty) return null;
|
|
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
// stat 一次,层 1/层 2 共用。
|
|
|
|
|
|
final picks = await _statPicks(sourcePaths);
|
|
|
|
|
|
if (picks.isEmpty) return null;
|
|
|
|
|
|
|
2026-09-28 19:17:35 +08:00
|
|
|
|
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
|
2026-09-28 20:22:37 +08:00
|
|
|
|
final local = await _matchLocalHistory(
|
|
|
|
|
|
ref,
|
|
|
|
|
|
accountId: accountId,
|
|
|
|
|
|
albumId: albumId,
|
|
|
|
|
|
picks: picks,
|
|
|
|
|
|
);
|
2026-09-28 19:17:35 +08:00
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
// 层 2:服务器历史(bytes 命中,逐请求条目一条;失败静默降级)。
|
2026-09-28 19:17:35 +08:00
|
|
|
|
final serverHits = <UploadDuplicateHit>[];
|
2026-09-28 20:22:37 +08:00
|
|
|
|
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 仍是兜底)。
|
2026-09-28 19:17:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 20:22:37 +08:00
|
|
|
|
// 合并去重(sourcePath 唯一标识一张本批照片;putIfAbsent 保留先见
|
|
|
|
|
|
// 者——本机在前,故同一重复的 uploadedAt 取本地历史)。
|
|
|
|
|
|
final byPath = <String, DuplicateMatch>{};
|
2026-09-28 19:17:35 +08:00
|
|
|
|
for (final match in [
|
|
|
|
|
|
...local,
|
|
|
|
|
|
for (final hit in serverHits)
|
|
|
|
|
|
if (hit.index >= 0 && hit.index < picks.length)
|
|
|
|
|
|
DuplicateMatch(
|
2026-09-28 20:22:37 +08:00
|
|
|
|
sourcePath: picks[hit.index].path,
|
2026-09-28 19:17:35 +08:00
|
|
|
|
filename: picks[hit.index].filename,
|
|
|
|
|
|
bytes: hit.bytes,
|
|
|
|
|
|
uploadedAt: hit.uploadedAt,
|
|
|
|
|
|
),
|
|
|
|
|
|
]) {
|
2026-09-28 20:22:37 +08:00
|
|
|
|
byPath.putIfAbsent(match.sourcePath, () => match);
|
2026-09-28 19:17:35 +08:00
|
|
|
|
}
|
2026-09-28 20:22:37 +08:00
|
|
|
|
return byPath.isEmpty ? null : byPath.values.toList();
|
2026-09-28 19:17:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 04:47:31 +08:00
|
|
|
|
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
|
|
|
|
|
///
|
2026-09-28 17:06:19 +08:00
|
|
|
|
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
|
|
|
|
|
|
/// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload
|
|
|
|
|
|
/// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。
|
|
|
|
|
|
///
|
2026-09-28 11:06:40 +08:00
|
|
|
|
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
|
|
|
|
|
|
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
|
|
|
|
|
|
/// 不在此抛出)。
|
|
|
|
|
|
Future<EnqueueResult> enqueueForUpload(
|
2026-09-19 04:47:31 +08:00
|
|
|
|
WidgetRef ref, {
|
|
|
|
|
|
required String accountId,
|
|
|
|
|
|
required String albumId,
|
|
|
|
|
|
String? categoryId,
|
|
|
|
|
|
required List<String> sourcePaths,
|
2026-09-28 11:06:40 +08:00
|
|
|
|
List<String>? skipPaths,
|
2026-09-28 17:06:19 +08:00
|
|
|
|
List<String>? forcePaths,
|
2026-09-19 04:47:31 +08:00
|
|
|
|
}) async {
|
|
|
|
|
|
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
|
|
|
|
|
|
await ref.read(notificationPermissionProvider).ensureRequested();
|
|
|
|
|
|
final preparer = ref.read(uploadPreparerProvider);
|
2026-09-28 11:06:40 +08:00
|
|
|
|
|
|
|
|
|
|
// P0-1:跳过用户选择取消的重复项。
|
|
|
|
|
|
final skipSet = skipPaths?.toSet() ?? <String>{};
|
|
|
|
|
|
final effective = sourcePaths
|
|
|
|
|
|
.where((path) => !skipSet.contains(path))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
2026-09-28 17:06:19 +08:00
|
|
|
|
// Task 8:「全部上传」确认的强传项(重复项 force=true 落任务行)。
|
|
|
|
|
|
final forceSet = forcePaths?.toSet() ?? <String>{};
|
|
|
|
|
|
|
2026-09-28 13:53:42 +08:00
|
|
|
|
final results = await Future.wait(
|
|
|
|
|
|
effective.map((path) => preparer.prepare(
|
|
|
|
|
|
accountId: accountId,
|
|
|
|
|
|
albumId: albumId,
|
|
|
|
|
|
categoryId: categoryId,
|
|
|
|
|
|
sourcePath: path,
|
2026-09-28 17:06:19 +08:00
|
|
|
|
force: forceSet.contains(path),
|
2026-09-28 13:53:42 +08:00
|
|
|
|
)),
|
|
|
|
|
|
);
|
|
|
|
|
|
final enqueued = results
|
|
|
|
|
|
.where((task) => task.state == UploadTaskState.queued)
|
|
|
|
|
|
.length;
|
2026-09-28 11:06:40 +08:00
|
|
|
|
|
|
|
|
|
|
return EnqueueResult(
|
|
|
|
|
|
enqueued: enqueued,
|
|
|
|
|
|
duplicates: [],
|
|
|
|
|
|
skipped: sourcePaths.length - effective.length,
|
|
|
|
|
|
);
|
2026-09-19 04:47:31 +08:00
|
|
|
|
}
|