373 lines
13 KiB
Dart
373 lines
13 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
|
||
|
||
/// ============================================================
|
||
/// 【PickerService】图片选择服务(Web 自动降级)
|
||
///
|
||
/// 一、为什么包一层:
|
||
/// 业务页不用关心平台差异,统一调 showPickSheet / pickFromGallery /
|
||
/// pickFromCamera,返回统一的 PickedImage 模型,上传时直接拿去用。
|
||
///
|
||
/// 二、双平台策略(选型结论):
|
||
/// 1. 生产端(Android/iOS):wechat_assets_picker + wechat_camera_picker
|
||
/// → UI 对标微信 8.0.51,相册支持多选/预览/排序/压缩;
|
||
/// 2. 调试端(Web):image_picker(官方,唯一支持 Web 的选择器)
|
||
/// → 功能简化为单选/多选,保证桌面浏览器能跑通整条链路。
|
||
///
|
||
/// 三、高频坑:
|
||
/// 1. wechat 系列【不支持 Web】!直接 import 在 Web 上编译不过,
|
||
/// 所以本文件必须用 kIsWeb 在运行时分流,才能 Web 打包;
|
||
/// 2. AssetEntity.file 在 Android 上要先等 photo_manager 权限回调,
|
||
/// 本服务已统一在方法内 await,业务页不要再碰 picker 内部 API;
|
||
/// 3. Web 端没有 File 文件系统,统一返回 bytes(Uint8List),
|
||
/// 上传时对 Web 用 MultipartFile.fromBytes,原生用 fromFile。
|
||
/// 4. Web 端不要给 image_picker 传 imageQuality/maxWidth/maxHeight,
|
||
/// 否则浏览器可能在"选图阶段"就先重编码,PNG 会被提前转成 JPG,
|
||
/// 后续业务层再也拿不到真正的原始字节,压缩策略就会失真。
|
||
/// ============================================================
|
||
|
||
/// 选择来源
|
||
enum PickSource { camera, gallery }
|
||
|
||
/// 选中图片的统一数据模型(屏蔽两套 picker 的差异)
|
||
class PickedImage {
|
||
const PickedImage({this.path, this.bytes, this.name, this.entity});
|
||
|
||
/// 本地文件路径(原生有值,Web 为 null;上传用)
|
||
final String? path;
|
||
|
||
/// 图片字节(Web 有值,原生为 null;Web 预览/上传用)
|
||
final Uint8List? bytes;
|
||
|
||
/// 文件名(上传时用,含扩展名)
|
||
final String? name;
|
||
|
||
/// 原生相册实体(wechat_assets_picker 返回,原生端缩略图预览用)
|
||
final AssetEntity? entity;
|
||
}
|
||
|
||
class PickerService {
|
||
PickerService._();
|
||
|
||
static final PickerService instance = PickerService._();
|
||
|
||
/// 底部弹层选择:拍照 / 从相册
|
||
/// 返回 null = 用户取消;返回空数组 = 用户确定但没选任何图
|
||
Future<List<PickedImage>?> showPickSheet(
|
||
BuildContext context, {
|
||
int maxCount = 9,
|
||
}) async {
|
||
final source = await showModalBottomSheet<PickSource>(
|
||
context: context,
|
||
backgroundColor: Colors.white,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
builder: (ctx) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(height: 8),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_camera_outlined,
|
||
color: Color(0xFF3B82F6)),
|
||
title: const Text('拍照'),
|
||
onTap: () => Navigator.pop(ctx, PickSource.camera),
|
||
),
|
||
const Divider(height: 1, indent: 56),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library_outlined,
|
||
color: Color(0xFF3B82F6)),
|
||
title: const Text('从相册选择'),
|
||
onTap: () => Navigator.pop(ctx, PickSource.gallery),
|
||
),
|
||
SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
|
||
if (source == null) return null;
|
||
if (!context.mounted) return null;
|
||
if (source == PickSource.camera) {
|
||
final ok = await _ensurePermission(
|
||
context: context,
|
||
permission: Permission.camera,
|
||
title: '需要相机权限',
|
||
purpose: '拍摄图片用于上传错题、作业图片及更换头像',
|
||
);
|
||
if (!ok) return null;
|
||
if (!context.mounted) return null;
|
||
final image = await pickFromCamera(context);
|
||
return image == null ? null : [image];
|
||
}
|
||
// 相册权限:Android 13+ 用 photos (READ_MEDIA_IMAGES),旧版用 storage
|
||
final galleryPermission = await _resolveGalleryPermission();
|
||
if (!context.mounted) return null;
|
||
final ok = await _ensurePermission(
|
||
permission: galleryPermission,
|
||
title: '需要相册权限',
|
||
purpose: '从相册选取图片用于上传错题、作业图片及更换头像',
|
||
context: context,
|
||
);
|
||
if (!ok) return null;
|
||
if (!context.mounted) return null;
|
||
return pickFromGallery(context, maxCount: maxCount);
|
||
}
|
||
|
||
/// 从相册选择(最多 maxCount 张)
|
||
Future<List<PickedImage>?> pickFromGallery(
|
||
BuildContext context, {
|
||
int maxCount = 9,
|
||
}) async {
|
||
// ---- Web:官方 image_picker 兜底 ----
|
||
if (kIsWeb) {
|
||
final files = await ImagePicker().pickMultiImage(
|
||
limit: maxCount,
|
||
);
|
||
if (files.isEmpty) return null;
|
||
final result = <PickedImage>[];
|
||
for (final f in files) {
|
||
result.add(
|
||
PickedImage(
|
||
path: f.path,
|
||
bytes: await f.readAsBytes(),
|
||
name: f.name,
|
||
),
|
||
);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ---- 原生:微信风格相册(中文) ----
|
||
final assets = await AssetPicker.pickAssets(
|
||
context,
|
||
pickerConfig: AssetPickerConfig(
|
||
maxAssets: maxCount,
|
||
requestType: RequestType.image,
|
||
textDelegate: const AssetPickerTextDelegate(),
|
||
),
|
||
);
|
||
if (assets == null || assets.isEmpty) return null;
|
||
final result = <PickedImage>[];
|
||
for (final asset in assets) {
|
||
final file = await asset.file;
|
||
result.add(
|
||
PickedImage(
|
||
path: file?.path,
|
||
name: file?.path.split('/').last,
|
||
entity: asset,
|
||
),
|
||
);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// 拍照(单张)
|
||
Future<PickedImage?> pickFromCamera(BuildContext context) async {
|
||
// ---- Web:image_picker 拍照 ----
|
||
if (kIsWeb) {
|
||
final file = await ImagePicker().pickImage(
|
||
source: ImageSource.camera,
|
||
);
|
||
if (file == null) return null;
|
||
return PickedImage(
|
||
path: file.path,
|
||
bytes: await file.readAsBytes(),
|
||
name: file.name,
|
||
);
|
||
}
|
||
|
||
// ---- 原生:微信风格相机(中文,只拍照,不录视频) ----
|
||
PickedImage? picked;
|
||
await CameraPicker.pickFromCamera(
|
||
context,
|
||
pickerConfig: CameraPickerConfig(
|
||
enableRecording: false,
|
||
textDelegate: const CameraPickerTextDelegate(),
|
||
// 不再强制锁拍照方向。题目/解答/讲义拍摄经常需要横拍,
|
||
// 后续交给 ImageProcessor 的原生压缩链做 autoCorrectionAngle。
|
||
onEntitySaving: (dialogContext, viewType, file) async {
|
||
// 只持有文件路径,不 readAsBytes:原图字节不进 Dart 堆,
|
||
// 后续压缩由原生侧直接读文件(避免大图 OOM)。
|
||
picked = PickedImage(
|
||
path: file.path,
|
||
name: file.path.split('/').last,
|
||
);
|
||
if (dialogContext.mounted) {
|
||
Navigator.of(dialogContext)
|
||
..pop(null)
|
||
..pop(null);
|
||
}
|
||
},
|
||
),
|
||
);
|
||
return picked;
|
||
}
|
||
|
||
/// 渲染选中图片的缩略图(双端统一,业务页直接用它当 Image 用)
|
||
///
|
||
/// 优先原生的 wechat 实体缩略图(秒开不卡顿),
|
||
/// Web 兜底用字节渲染(Image.memory),
|
||
/// 两者都没有时给灰色占位。
|
||
static Widget buildThumb(
|
||
PickedImage image, {
|
||
BoxFit fit = BoxFit.cover,
|
||
double? width,
|
||
double? height,
|
||
}) {
|
||
if (image.entity != null) {
|
||
// 原生:wechat 实体缩略图(快)
|
||
return AssetEntityImage(
|
||
image.entity!,
|
||
isOriginal: false,
|
||
fit: fit,
|
||
width: width,
|
||
height: height,
|
||
);
|
||
}
|
||
if (image.bytes != null) {
|
||
// Web:字节渲染
|
||
return Image.memory(
|
||
image.bytes!,
|
||
fit: fit,
|
||
width: width,
|
||
height: height,
|
||
);
|
||
}
|
||
// 兜底:都不存在时灰色占位
|
||
return ColoredBox(color: const Color(0xFFEEF1F5));
|
||
}
|
||
|
||
// ==================== 权限守卫(Android 合规必做) ====================
|
||
|
||
/// 根据 Android 版本返回相册对应的 Permission:
|
||
/// Android 13+ (SDK 33) → Permission.photos (READ_MEDIA_IMAGES)
|
||
/// Android 12 以下 → Permission.storage (READ_EXTERNAL_STORAGE)
|
||
/// iOS → Permission.photos
|
||
///
|
||
/// 判断策略:先尝试 Permission.photos.status,
|
||
/// 如果抛异常说明系统不认识这个权限(Android < 13),回退 storage。
|
||
static Future<Permission> _resolveGalleryPermission() async {
|
||
try {
|
||
// 能正常查询 → Android 13+ 或 iOS
|
||
await Permission.photos.status;
|
||
return Permission.photos;
|
||
} catch (_) {
|
||
// 查询抛异常 → Android < 13,用 storage
|
||
return Permission.storage;
|
||
}
|
||
}
|
||
|
||
/// 权限守卫:前置弹窗说明用途 → 请求系统权限 → 永久拒绝时引导去设置
|
||
///
|
||
/// 为什么需要这一层:
|
||
/// Android 上架审核(应用宝 / 华为 / 小米)要求:
|
||
/// 调用系统权限弹窗之前,必须先弹自定义弹窗告知用户权限用途。
|
||
/// 直接弹系统权限框会被审核打回。
|
||
/// iOS 的 Info.plist 描述字符串已经能说明用途,不需要这层。
|
||
///
|
||
/// 返回 true = 已授权,可以继续操作;false = 用户拒绝或取消
|
||
Future<bool> _ensurePermission({
|
||
required BuildContext context,
|
||
required Permission permission,
|
||
required String title,
|
||
required String purpose,
|
||
}) async {
|
||
// Web 不需要系统权限(浏览器自己处理)
|
||
if (kIsWeb) return true;
|
||
|
||
// ① 已授权 → 直接放行
|
||
final status = await permission.status;
|
||
if (status.isGranted) return true;
|
||
|
||
// ② 永久拒绝 → 引导去系统设置
|
||
if (status.isPermanentlyDenied) {
|
||
if (!context.mounted) return false;
|
||
final goSetting = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('权限已被拒绝'),
|
||
content: Text(
|
||
'您已关闭相关权限且选择了不再询问,'
|
||
'请前往系统设置手动开启权限后重试。',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: const Text('去设置'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (goSetting == true) {
|
||
await openAppSettings();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ③ 首次请求 / 被拒绝过但还能再请求 → 先弹前置说明弹窗
|
||
if (!context.mounted) return false;
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(title),
|
||
content: Text(
|
||
'$purpose。权限仅用于上述功能,不会读取无关内容。',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: const Text('知晓并继续'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true) return false;
|
||
|
||
// ④ 用户确认 → 触发系统权限弹窗
|
||
final result = await permission.request();
|
||
if (result.isGranted) return true;
|
||
|
||
// ⑤ 刚请求就被拒绝 → 检查是否永久拒绝,如果是引导去设置
|
||
if (result.isPermanentlyDenied) {
|
||
if (!context.mounted) return false;
|
||
final goSetting = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('权限申请失败'),
|
||
content: const Text(
|
||
'权限被拒绝,如需继续使用此功能,请前往系统设置手动开启。',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('稍后再说'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: const Text('去设置'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (goSetting == true) {
|
||
await openAppSettings();
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|