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?> showPickSheet( BuildContext context, { int maxCount = 9, }) async { final source = await showModalBottomSheet( 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 ok = await _ensurePermission( permission: await _resolveGalleryPermission(), title: '需要相册权限', purpose: '从相册选取图片用于上传错题、作业图片及更换头像', context: context, ); if (!ok) return null; if (!context.mounted) return null; return pickFromGallery(context, maxCount: maxCount); } /// 从相册选择(最多 maxCount 张) Future?> 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 = []; 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 = []; 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 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 { final xFile = XFile(file.path); picked = PickedImage( path: file.path, bytes: await xFile.readAsBytes(), 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 _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 _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( 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( 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( 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; } }