2026-09-19 04:47:31 +08:00
|
|
|
|
/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// image_picker 的平台调用经 Provider 注入([singleImagePickerProvider]/
|
|
|
|
|
|
/// [multiImagePickerProvider]),widget 测试 override 为替身——不经平台
|
|
|
|
|
|
/// 通道即可驱动入队流;[enqueueForUpload] 为连拍页与相册详情 FAB 的共用
|
|
|
|
|
|
/// 入队路径:首次动作触发 POST_NOTIFICATIONS 请求(静默降级)+ 逐张经
|
|
|
|
|
|
/// 准备器入队(准备器内部串行复制,防连拍 IO 风暴)。
|
2026-09-28 11:06:40 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
|
|
|
|
|
|
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
|
|
|
|
|
|
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
|
2026-09-19 04:47:31 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
2026-09-28 11:06:40 +08:00
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
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-19 04:47:31 +08:00
|
|
|
|
|
|
|
|
|
|
import '../../core/notification_permission.dart';
|
|
|
|
|
|
import 'providers.dart';
|
|
|
|
|
|
import 'task_state.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
|
|
|
|
|
|
typedef SingleImagePicker = Future<String?> Function();
|
|
|
|
|
|
|
|
|
|
|
|
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
|
|
|
|
|
|
typedef MultiImagePicker = Future<List<String>> Function();
|
|
|
|
|
|
|
|
|
|
|
|
/// 单图选择 Provider(生产=image_picker;测试 override)。
|
|
|
|
|
|
final singleImagePickerProvider = Provider<SingleImagePicker>(
|
|
|
|
|
|
(ref) => () async {
|
|
|
|
|
|
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
|
|
|
|
|
|
return file?.path;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/// 多图选择 Provider(生产=image_picker;测试 override)。
|
|
|
|
|
|
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
|
|
|
|
|
(ref) => () async {
|
|
|
|
|
|
final files = await ImagePicker().pickMultiImage();
|
|
|
|
|
|
return files.map((file) => file.path).toList();
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-28 11:06:40 +08:00
|
|
|
|
/// 查重结果(单张):匹配到的已上传任务摘要。
|
|
|
|
|
|
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<DuplicateMatch> duplicates;
|
|
|
|
|
|
|
|
|
|
|
|
/// 用户选择跳过的重复项张数。
|
|
|
|
|
|
final int skipped;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 入队前查重:查 TaskStore 同账号 + 同相册 + state=uploaded,
|
|
|
|
|
|
/// filename + bytes 匹配的记录。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 返回 null = 无重复(可直接入队);非 null = 有疑似重复(调用方弹窗)。
|
|
|
|
|
|
Future<List<DuplicateMatch>?> checkDuplicates(
|
|
|
|
|
|
WidgetRef ref, {
|
|
|
|
|
|
required String accountId,
|
|
|
|
|
|
required String albumId,
|
|
|
|
|
|
required List<String> 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 = <DuplicateMatch>[];
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 04:47:31 +08:00
|
|
|
|
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
|
|
|
|
|
///
|
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-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-19 04:47:31 +08:00
|
|
|
|
var enqueued = 0;
|
2026-09-28 11:06:40 +08:00
|
|
|
|
for (final path in effective) {
|
2026-09-19 04:47:31 +08:00
|
|
|
|
final task = await preparer.prepare(
|
|
|
|
|
|
accountId: accountId,
|
|
|
|
|
|
albumId: albumId,
|
|
|
|
|
|
categoryId: categoryId,
|
|
|
|
|
|
sourcePath: path,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (task.state == UploadTaskState.queued) enqueued++;
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|