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>
This commit is contained in:
浅念
2026-09-28 14:43:59 +08:00
parent aea797c7c7
commit 2e5fc40636
9 changed files with 216 additions and 26 deletions
@@ -9,6 +9,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 应用内相册选择器(wechat_assets_picker/photo_manager):图片读取。
READ_MEDIA_VISUAL_USER_SELECTED:API 34 引入、无上限,勿加
maxSdkVersion——加了会在 Android 15+(targetSdk 36)被裁掉,
部分访问的「选择照片」入口随之消失,picker limited/manage 流程失效。 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<application
android:usesCleartextTraffic="true"
android:label="photo_live"
@@ -93,7 +93,7 @@ class ItAppHarness {
ref.onDispose(() => 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;
+44 -10
View File
@@ -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<String?> Function();
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function();
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
/// 单图选择 Provider(生产=image_picker;测试 override)。
final singleImagePickerProvider = Provider<SingleImagePicker>(
@@ -35,14 +38,45 @@ final singleImagePickerProvider = Provider<SingleImagePicker>(
},
);
/// 多图选择 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<MultiImagePicker>(
(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 <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({
+2 -2
View File
@@ -337,8 +337,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
}
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);
+1 -1
View File
@@ -304,7 +304,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
Future<void> _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);
+154 -10
View File
@@ -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:
+2
View File
@@ -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
@@ -336,7 +336,7 @@ void main() {
client: clientOf(adapter),
),
multiImagePickerProvider
.overrideWith((ref) => () async => galleryPaths),
.overrideWith((ref) => (context) async => galleryPaths),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
taskStore,
@@ -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,