diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index a6519e9..b439366 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,16 @@ + + + + client.close()); return client; }), - multiImagePickerProvider.overrideWithValue(() async => picker.consume()), + multiImagePickerProvider.overrideWithValue((context) async => picker.consume()), singleImagePickerProvider.overrideWithValue(() async { final paths = picker.consume(); return paths.isEmpty ? null : paths.first; diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index 62f36e3..e62df7c 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -1,10 +1,11 @@ /// 选图注入点 + 批量入队动作(P3 计划 Task 9)。 /// -/// image_picker 的平台调用经 Provider 注入([singleImagePickerProvider]/ -/// [multiImagePickerProvider]),widget 测试 override 为替身——不经平台 -/// 通道即可驱动入队流;[enqueueForUpload] 为连拍页与相册详情 FAB 的共用 -/// 入队路径:首次动作触发 POST_NOTIFICATIONS 请求(静默降级)+ 逐张经 -/// 准备器入队(准备器内部串行复制,防连拍 IO 风暴)。 +/// 选图的平台调用经 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],由 @@ -13,9 +14,11 @@ 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'; @@ -25,7 +28,7 @@ import 'task_state.dart'; typedef SingleImagePicker = Future Function(); /// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。 -typedef MultiImagePicker = Future> Function(); +typedef MultiImagePicker = Future> Function(BuildContext context); /// 单图选择 Provider(生产=image_picker;测试 override)。 final singleImagePickerProvider = Provider( @@ -35,14 +38,45 @@ final singleImagePickerProvider = Provider( }, ); -/// 多图选择 Provider(生产=image_picker;测试 override)。 +/// 多图选择 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) => () async { - final files = await ImagePicker().pickMultiImage(); - return files.map((file) => file.path).toList(); + (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; + /// 查重结果(单张):匹配到的已上传任务摘要。 class DuplicateMatch { const DuplicateMatch({ diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 600dd5f..8fe0eaf 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -337,8 +337,8 @@ class _AlbumDetailPageState extends ConsumerState { } final l10n = AppLocalizations.of(context); final account = await _currentAccountId(); - if (account == null) return; - final paths = await ref.read(multiImagePickerProvider)(); + if (account == null || !mounted) return; + final paths = await ref.read(multiImagePickerProvider)(context); if (paths.isEmpty || !mounted) return; final messenger = ScaffoldMessenger.of(context); diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index 5673c4f..a5f22e1 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -304,7 +304,7 @@ class _CapturePageState extends ConsumerState /// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。 Future _pickFromGallery() async { - final paths = await ref.read(multiImagePickerProvider)(); + final paths = await ref.read(multiImagePickerProvider)(context); if (paths.isEmpty || !mounted) return; final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index e548fb8..00cf77f 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -109,26 +109,26 @@ packages: dependency: transitive description: name: camera_android_camerax - sha256: "977f54512d338b3b36d7f82929794886766d8f41ae84a48fe103eb0106ecfe25" + sha256: a9d527411429f97976dc1f97b0dbe57560c9d3ae482e2f45b7d0a2a61c6bf8f9 url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.4+8" + version: "0.7.5" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "843247230a583e04742ea1434ce3f91e536760922fab67c72ce46aa34fac3e9d" + sha256: e409a731fad3fca10daa9567cf981720398659e6390f871a4e0f576058ff8940 url: "https://pub.flutter-io.cn" source: hosted - version: "0.10.3" + version: "0.10.3+1" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + sha256: "9365babc7488a9e93d0ed6dcccd917619fb12ea6f9e601519e1191d4890ee2fe" url: "https://pub.flutter-io.cn" source: hosted - version: "2.13.1" + version: "2.14.0" camera_web: dependency: transitive description: @@ -233,6 +233,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" dart_style: dependency: transitive description: @@ -281,6 +289,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.35.0" + extended_image: + dependency: transitive + description: + name: extended_image + sha256: e81d801cd692921f152834e01f39bba8b3d6302c48ad420d499c6062a7e24737 + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.1.0" + extended_image_library: + dependency: transitive + description: + name: extended_image_library + sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.1" fake_async: dependency: transitive description: @@ -462,10 +486,10 @@ packages: dependency: "direct main" description: name: fluwx - sha256: "4b526d281be8560a490bd1b945373a23ab2a12088c8fc5997ffb31cc6fb41082" + sha256: "766439fadfcaf29063cd89b330953d46f395b79a8840e377a060e2f6ada9c187" url: "https://pub.flutter-io.cn" source: hosted - version: "6.0.2" + version: "6.0.4" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -503,6 +527,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.15.7" http: dependency: transitive description: @@ -511,6 +543,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" + http_client_helper: + dependency: transitive + description: + name: http_client_helper + sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" http_multi_server: dependency: transitive description: @@ -563,10 +603,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050" url: "https://pub.flutter-io.cn" source: hosted - version: "0.8.13+7" + version: "0.8.13+8" image_picker_linux: dependency: transitive description: @@ -644,6 +684,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -740,6 +788,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.19.2" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" nm: dependency: transitive description: @@ -828,6 +884,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" + photo_manager: + dependency: transitive + description: + name: photo_manager + sha256: "2d00a785b501bf1e5f6180bbef772846e767be11d45d11112b63092ef54a419e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.12.0" + photo_manager_image_provider: + dependency: transitive + description: + name: photo_manager_image_provider + sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" platform: dependency: transitive description: @@ -868,6 +940,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.0.6" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1217,6 +1297,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" + video_player: + dependency: transitive + description: + name: video_player + sha256: "8c837b570dccb9ae6ff73d2e0b03c7e708bfefd3bd1194faa7f3e7f200dfc399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.14.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.12.2" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "2fc56e537324a19eb649fca991aa308ac961b5406292876fa657b062de461fd8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.12.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0+2" vm_service: dependency: transitive description: @@ -1265,6 +1393,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.2.0" + wechat_assets_picker: + dependency: "direct main" + description: + name: wechat_assets_picker + sha256: ccd92f3e8c968e2cbad939bce82b454e798a7669c1e4ab74e871822ce2440b2d + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.1.3" + wechat_picker_library: + dependency: transitive + description: + name: wechat_picker_library + sha256: "5cb61b9aa935b60da5b043f8446fbb9c5077419f20ccc4856bf444aec4f44bc1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.7" win32: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index f9322c9..b5cf839 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: shared_preferences: ^2.5.3 # 选图 / 连拍 image_picker: ^1.2.3 + # 应用内相册选择器(连选/全选/无系统 100 张上限;基于 photo_manager) + wechat_assets_picker: ^10.1.3 camera: ^0.12.1 # 分享二维码 qr_flutter: ^4.1.0 diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index e981645..4f6695e 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -336,7 +336,7 @@ void main() { client: clientOf(adapter), ), multiImagePickerProvider - .overrideWith((ref) => () async => galleryPaths), + .overrideWith((ref) => (context) async => galleryPaths), uploadPreparerProvider.overrideWith( (ref) => UploadPreparer( taskStore, diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart index 7e8414e..ef199f3 100644 --- a/apps/mobile/test/pages/capture_page_test.dart +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -177,7 +177,7 @@ void main() { (ref) => NotificationPermission(isAndroid: false), ), multiImagePickerProvider - .overrideWith((ref) => () async => galleryPaths), + .overrideWith((ref) => (context) async => galleryPaths), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates,