Files
photo-live/apps/mobile/lib/features/uploads/enqueue.dart
T
浅念 2e5fc40636 feat(app): in-app asset picker (wechat_assets_picker) - unlimited multi-select with range selection
Review fixes: per-asset originFile error isolation (failed asset skipped, not whole batch); drop maxSdkVersion from READ_MEDIA_VISUAL_USER_SELECTED (API 34+, no upper bound); originFile doc accuracy (API 30+ direct path vs Android 10 ScopedCache copy).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 15:03:44 +08:00

204 lines
7.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 选图注入点 + 批量入队动作(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],由
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
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<String?> Function();
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
/// 单图选择 Provider(生产=image_picker;测试 override)。
final singleImagePickerProvider = Provider<SingleImagePicker>(
(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<MultiImagePicker>(
(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,
];
},
);
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;
/// 查重结果(单张):匹配到的已上传任务摘要。
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;
}
/// 批量入队:通知权限单次请求 + 逐张准备入队。
///
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
/// 不在此抛出)。
Future<EnqueueResult> enqueueForUpload(
WidgetRef ref, {
required String accountId,
required String albumId,
String? categoryId,
required List<String> sourcePaths,
List<String>? skipPaths,
}) async {
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
await ref.read(notificationPermissionProvider).ensureRequested();
final preparer = ref.read(uploadPreparerProvider);
// P0-1:跳过用户选择取消的重复项。
final skipSet = skipPaths?.toSet() ?? <String>{};
final effective = sourcePaths
.where((path) => !skipSet.contains(path))
.toList();
final results = await Future.wait(
effective.map((path) => preparer.prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: path,
)),
);
final enqueued = results
.where((task) => task.state == UploadTaskState.queued)
.length;
return EnqueueResult(
enqueued: enqueued,
duplicates: [],
skipped: sourcePaths.length - effective.length,
);
}