flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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 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<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 {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user