Files
App.K12.Student.Miniapp_Flu…/lib/core/services/picker_service.dart
T
2026-09-28 20:12:34 +08:00

373 lines
13 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.
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;
}
}