flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
/// ============================================================
/// 【ImageProcessor】图片压缩 + 方向修正 + 尺寸过滤
///
/// 一、平台策略:
/// 1. Android/iOS/macOS 的拍照/JPG:优先走 flutter_image_compress
/// → 原生编解码器 + autoCorrectionAngle:true,直接修正拍照方向;
/// 2. Web 与 PNG:继续走 image 纯 Dart 方案,避免 PNG 被原生重编码后变大;
/// 3. 两条链都先做尺寸过滤:宽高必须在 14~4000 像素之间。
///
/// 二、为什么这样分:
/// 1. 本次真机调试已经确认:部分机型拍照文件没有可用 EXIF orientation,
/// 只靠 Dart 侧 bakeOrientation 不够稳;
/// 2. flutter_image_compress 的 autoCorrectionAngle 更适合原生拍照文件;
/// 3. PNG/截图则更适合保留现有纯 Dart 方案,避免体积反涨。
///
/// 三、高频坑:
/// 1. Flutter 布局本质是:父组件给子组件约束,子组件决定自身大小。
/// 本服务只处理字节,不参与布局,但上传预览时别把大图直接塞进
/// 无约束的 Row/Column,否则照样会溢出;
/// 2. image 包的 `Image` 类和 Flutter 的 `Image` widget 同名,
/// 所以这里必须 `as img`;
/// 3. 原生压缩不是所有平台都支持,Web/Windows 会自动回退纯 Dart 路径。
/// ============================================================
class ProcessedImage {
const ProcessedImage({
required this.bytes,
required this.extension,
required this.contentType,
required this.originalBytes,
required this.processedBytes,
});
final Uint8List bytes;
final String extension;
final String contentType;
final int originalBytes;
final int processedBytes;
}
class ImageProcessor {
ImageProcessor._();
static const int minWidth = 14;
static const int minHeight = 14;
static const int maxWidth = 4000;
static const int maxHeight = 4000;
static Future<ProcessedImage?> process({
required Uint8List bytes,
String? fileName,
int maxDimension = 1920,
int quality = 85,
}) async {
final inputMeta = _detectInputMeta(bytes, fileName);
if (_shouldUseNativeCompressor(inputMeta.kind)) {
final native = await _processWithNative(
bytes: bytes,
fileName: fileName,
maxDimension: maxDimension,
quality: quality,
inputMeta: inputMeta,
);
if (native != null) return native;
}
final result = await compute(
_processInIsolate,
(
bytes: bytes,
fileName: fileName,
maxDimension: maxDimension,
quality: quality,
),
);
if (result == null) return null;
return ProcessedImage(
bytes: result.bytes,
extension: result.extension,
contentType: result.contentType,
originalBytes: result.originalBytes,
processedBytes: result.processedBytes,
);
}
}
typedef _ProcessTask = ({
Uint8List bytes,
String? fileName,
int maxDimension,
int quality,
});
typedef _ProcessResult = ({
Uint8List bytes,
String extension,
String contentType,
int originalBytes,
int processedBytes,
});
typedef _ImageMeta = ({
_EncodedKind kind,
String extension,
String contentType,
});
typedef _TargetSize = ({int width, int height});
enum _EncodedKind { jpeg, png, other }
bool _shouldUseNativeCompressor(_EncodedKind kind) {
if (kIsWeb || kind == _EncodedKind.png) return false;
return switch (defaultTargetPlatform) {
TargetPlatform.android => true,
TargetPlatform.iOS => true,
TargetPlatform.macOS => true,
_ => false,
};
}
Future<ProcessedImage?> _processWithNative({
required Uint8List bytes,
required String? fileName,
required int maxDimension,
required int quality,
required _ImageMeta inputMeta,
}) async {
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
if (!_isInAllowedSize(decoded.width, decoded.height)) {
return null;
}
final target = _targetSize(
width: decoded.width,
height: decoded.height,
maxDimension: maxDimension,
);
final compressed = await FlutterImageCompress.compressWithList(
bytes,
minWidth: target.width,
minHeight: target.height,
quality: quality,
format:
inputMeta.kind == _EncodedKind.png ? CompressFormat.png : CompressFormat.jpeg,
autoCorrectionAngle: true,
);
final outputBytes =
compressed.isNotEmpty ? Uint8List.fromList(compressed) : bytes;
final outputMeta = _detectOutputMeta(
originalMeta: inputMeta,
outputBytes: outputBytes,
);
return ProcessedImage(
bytes: outputBytes,
extension: outputMeta.extension,
contentType: outputMeta.contentType,
originalBytes: bytes.length,
processedBytes: outputBytes.length,
);
}
_ProcessResult? _processInIsolate(_ProcessTask task) {
final inputMeta = _detectInputMeta(task.bytes, task.fileName);
final decoded = img.decodeImage(task.bytes);
if (decoded == null) return null;
if (!_isInAllowedSize(decoded.width, decoded.height)) {
return null;
}
final hasOrientation =
decoded.exif.imageIfd.hasOrientation &&
decoded.exif.imageIfd.orientation != 1;
final oriented = img.bakeOrientation(decoded);
var output = oriented;
var resized = false;
final longest = oriented.width >= oriented.height ? oriented.width : oriented.height;
if (longest > task.maxDimension) {
resized = true;
final scale = task.maxDimension / longest;
output = img.copyResize(
oriented,
width: (oriented.width * scale).round(),
height: (oriented.height * scale).round(),
);
}
if (!hasOrientation && !resized) {
final originalMeta = _preserveOriginalMeta(task.bytes, task.fileName);
return (
bytes: task.bytes,
extension: originalMeta.extension,
contentType: originalMeta.contentType,
originalBytes: task.bytes.length,
processedBytes: task.bytes.length,
);
}
final encoded = switch (inputMeta.kind) {
_EncodedKind.png => img.encodePng(output),
_ => img.encodeJpg(output, quality: task.quality),
};
return (
bytes: Uint8List.fromList(encoded),
extension: inputMeta.extension,
contentType: inputMeta.contentType,
originalBytes: task.bytes.length,
processedBytes: encoded.length,
);
}
bool _isInAllowedSize(int width, int height) {
return width >= ImageProcessor.minWidth &&
height >= ImageProcessor.minHeight &&
width <= ImageProcessor.maxWidth &&
height <= ImageProcessor.maxHeight;
}
_TargetSize _targetSize({
required int width,
required int height,
required int maxDimension,
}) {
final longest = width >= height ? width : height;
if (longest <= maxDimension) return (width: width, height: height);
final scale = maxDimension / longest;
return (
width: (width * scale).round(),
height: (height * scale).round(),
);
}
_ImageMeta _detectInputMeta(Uint8List bytes, String? fileName) {
if (_isPng(bytes)) {
return (
kind: _EncodedKind.png,
extension: 'png',
contentType: 'image/png',
);
}
if (_isJpeg(bytes)) {
return (
kind: _EncodedKind.jpeg,
extension: 'jpg',
contentType: 'image/jpeg',
);
}
final ext = _extensionFromName(fileName);
if (ext == 'png') {
return (
kind: _EncodedKind.png,
extension: 'png',
contentType: 'image/png',
);
}
return (
kind: _EncodedKind.other,
extension: 'jpg',
contentType: 'image/jpeg',
);
}
_ImageMeta _detectOutputMeta({
required _ImageMeta originalMeta,
required Uint8List outputBytes,
}) {
if (_isPng(outputBytes)) {
return (
kind: _EncodedKind.png,
extension: 'png',
contentType: 'image/png',
);
}
if (_isJpeg(outputBytes)) {
return (
kind: _EncodedKind.jpeg,
extension: 'jpg',
contentType: 'image/jpeg',
);
}
return originalMeta;
}
({String extension, String contentType}) _preserveOriginalMeta(
Uint8List bytes,
String? fileName,
) {
final ext = _extensionFromName(fileName);
switch (ext) {
case 'png':
return (extension: 'png', contentType: 'image/png');
case 'jpg':
case 'jpeg':
return (extension: 'jpg', contentType: 'image/jpeg');
case 'webp':
return (extension: 'webp', contentType: 'image/webp');
case 'gif':
return (extension: 'gif', contentType: 'image/gif');
}
if (_isPng(bytes)) return (extension: 'png', contentType: 'image/png');
if (_isJpeg(bytes)) return (extension: 'jpg', contentType: 'image/jpeg');
return (extension: 'jpg', contentType: 'image/jpeg');
}
String _extensionFromName(String? fileName) {
if (fileName == null || !fileName.contains('.')) return '';
return fileName.split('.').last.toLowerCase();
}
bool _isPng(Uint8List bytes) {
return bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47;
}
bool _isJpeg(Uint8List bytes) {
return bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF;
}
@@ -0,0 +1,190 @@
/*
经验教训(Android 尤其是 vivo 等强省电 ROM):
1) 立即通知(show)相对可靠:只要通知权限/渠道允许,通常能稳定投递。
2) 定时通知(zonedSchedule)不可靠:即使 pending=1 表示“已注册到系统”,系统仍可能因为省电/后台冻结/厂商策略而不触发投递(active 不出现该 id)。
3) exact 能力≠一定准点:canExact=true 仅代表系统允许“精确定时”接口,实际触发仍可能被 ROM 延迟/拦截;短延时(15 秒/1 分钟)更容易受影响。
4) 调试定时链路的最小证据:canPost/canExact + pendingNotificationRequests + getActiveNotifications(能区分“没安排”vs“已安排但没触发”vs“已投递但展示被静默”)。
5) 业务侧策略:做题过程的准点提醒优先用前台 Timer;必须后台稳定提醒建议用服务端推送(FCM/APNs)而不是本地闹钟。
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/router/app_router.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
class LocalNotificationService {
LocalNotificationService._();
static final LocalNotificationService instance = LocalNotificationService._();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
int _newNotificationId() {
final id = DateTime.now().microsecondsSinceEpoch & 0x7fffffff;
return id == 0 ? 1 : id;
}
Future<void> ensureInitialized() async {
if (_initialized) return;
if (kIsWeb) {
_initialized = true;
return;
}
tz_data.initializeTimeZones();
final tzInfo = await FlutterTimezone.getLocalTimezone();
try {
tz.setLocalLocation(tz.getLocation(tzInfo.identifier));
} catch (e) {
talker.warning('初始化时区失败:$e');
}
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings();
const initSettings =
InitializationSettings(android: androidInit, iOS: iosInit);
await _plugin.initialize(
settings: initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
_initialized = true;
final details = await _plugin.getNotificationAppLaunchDetails();
final payload = details?.notificationResponse?.payload;
if (details?.didNotificationLaunchApp == true && payload != null) {
_go(payload);
}
}
Future<bool> requestPermissions() async {
if (kIsWeb) return false;
final android =
_plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final ios =
_plugin.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
final androidGranted = await android?.requestNotificationsPermission();
final iosGranted = await ios?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
return (androidGranted ?? true) && (iosGranted ?? true);
}
Future<void> showMyPointNow() async {
await ensureInitialized();
await requestPermissions();
final id = _newNotificationId();
const androidDetails = AndroidNotificationDetails(
'reminder',
'提醒',
channelDescription: '学习提醒与业务通知',
importance: Importance.high,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails();
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _plugin.show(
id: id,
title: '通知测试(立即)',
body: '如果你看到这条,说明通知权限/渠道正常',
notificationDetails: details,
payload: '/myPoint',
);
talker.debug('已发送立即通知:id=$id');
}
Future<void> scheduleMyPointAfter15Seconds() async {
await ensureInitialized();
await requestPermissions();
final id = _newNotificationId();
final android =
_plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final canPost = await android?.areNotificationsEnabled();
var canExact = await android?.canScheduleExactNotifications();
if (canExact == false) {
await android?.requestExactAlarmsPermission();
canExact = await android?.canScheduleExactNotifications();
}
talker.debug('通知能力检测:canPost=$canPost canExact=$canExact');
const androidDetails = AndroidNotificationDetails(
'reminder',
'提醒',
channelDescription: '学习提醒与业务通知',
importance: Importance.high,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails();
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
final scheduled =
tz.TZDateTime.now(tz.local).add(const Duration(seconds: 15));
await _plugin.zonedSchedule(
id: id,
scheduledDate: scheduled,
notificationDetails: details,
androidScheduleMode: canExact == true
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle,
title: '积分提醒(15秒)',
body: '点我查看积分明细',
payload: '/myPoint',
);
final pending = await _plugin.pendingNotificationRequests();
talker.debug(
'已安排本地通知:pending=${pending.length} ids=${pending.map((e) => e.id).toList()}',
);
Timer(const Duration(seconds: 25), () async {
final pendingLater = await _plugin.pendingNotificationRequests();
final activeLater = await _plugin.getActiveNotifications();
talker.debug(
'25秒后:pending=${pendingLater.length} ids=${pendingLater.map((e) => e.id).toList()} active=${activeLater.length} ids=${activeLater.map((e) => e.id).toList()}',
);
});
}
static void _onNotificationResponse(NotificationResponse response) {
final payload = response.payload;
if (payload == null || payload.isEmpty) return;
_go(payload);
}
static void _go(String location) {
WidgetsBinding.instance.addPostFrameCallback((_) {
appRouter.go(location);
});
}
}
+370
View File
@@ -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;
}
}
+225
View File
@@ -0,0 +1,225 @@
import 'dart:io';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:gal/gal.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// ============================================================
/// 【ShareService】分享服务(系统分享面板统一封装)
///
/// 一、为什么包一层:
/// 业务页不用关心 share_plus 的 API 细节,统一调 shareText /
/// shareImage / shareFromWidget,返回统一的 ShareResult。
///
/// 二、能力:
/// 1. shareText → 分享纯文本 / 链接
/// 2. shareImage → 分享单张图片(本地文件)
/// 3. shareImages → 分享多张图片
/// 4. shareFromWidget → 把任意 Widget 截图成图片 → 分享(海报核心能力)
/// 5. saveToGallery → 保存图片到系统相册(Android/iOS)
///
/// 三、平台策略:
/// Android → ACTION_SEND Intent(系统分享面板,用户自选微信/QQ/微博等)
/// iOS → UIActivityViewController(系统分享面板)
/// Web → Web Share API(浏览器原生,不支持则降级下载)
///
/// 四、后续扩展:
/// 如需精确分享到微信好友/朋友圈,加 fluwx(微信 OpenSDK),
/// 在 ShareService 内部加分支判断,业务页不用改。
///
/// 五、高频坑:
/// 1. Android 分享图片必须用 FileProvider,share_plus 已内置处理,
/// 但传入的必须是真实文件路径(XFile),不能是 Uint8List 直接传;
/// 2. shareFromWidget 的截图依赖 RepaintBoundary,
/// Widget 必须在渲染树中(不能是离屏未 build 的);
/// 3. Web 端 shareXFiles 不支持 Linux,Windows 走 mailto。
/// ============================================================
class ShareService {
ShareService._();
static final ShareService instance = ShareService._();
// ==================== 文本分享 ====================
/// 分享文本 / 链接
///
/// [text] 要分享的内容(纯文本或 URL)
/// [subject] 邮件分享时的主题(可选)
Future<ShareResult> shareText({
required String text,
String? subject,
}) async {
return SharePlus.instance.share(
ShareParams(
text: text,
subject: subject,
),
);
}
// ==================== 图片分享 ====================
/// 分享单张图片(本地文件路径)
///
/// [filePath] 本地图片文件路径
/// [text] 附带的文字说明(可选,部分 App 会显示)
Future<ShareResult> shareImage({
required String filePath,
String? text,
}) async {
return shareImages(filePaths: [filePath], text: text);
}
/// 分享多张图片
///
/// [filePaths] 本地图片文件路径列表
/// [text] 附带的文字说明(可选)
Future<ShareResult> shareImages({
required List<String> filePaths,
String? text,
}) async {
final files = filePaths.map((p) => XFile(p)).toList();
return SharePlus.instance.share(
ShareParams(
files: files,
text: text,
),
);
}
/// 分享内存字节(Uint8List)为图片
///
/// 适用场景:网络图片下载后直接分享,不先写磁盘。
/// 内部自动写入临时目录再分享。
Future<ShareResult> shareImageBytes({
required Uint8List bytes,
String fileName = 'share_image.jpg',
String? text,
}) async {
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/$fileName');
await file.writeAsBytes(bytes);
return shareImage(filePath: file.path, text: text);
}
// ==================== Widget 截图分享(海报核心) ====================
/// 把任意 Widget 截图成图片 → 分享
///
/// 这是"攻克海报分享"的核心能力:
/// 1. 用 RepaintBoundary 包裹目标 Widget;
/// 2. 截图转 Uint8List;
/// 3. 写入临时文件 → 调系统分享。
///
/// [builder] 构建要截图的 Widget(通常是海报布局)
/// [text] 附带的文字说明
/// [pixelRatio] 截图清晰度(默认 3.0,越高越清晰但越慢)
Future<ShareResult> shareFromWidget({
required WidgetBuilder builder,
required BuildContext context,
String? text,
double pixelRatio = 3.0,
}) async {
final bytes = await captureWidget(
builder: builder,
context: context,
pixelRatio: pixelRatio,
);
if (bytes == null) {
return ShareResult.unavailable;
}
return shareImageBytes(bytes: bytes, text: text);
}
/// 把任意 Widget 截图为 Uint8List(PNG)
///
/// 独立方法:截图后可以做任何事(保存、上传、分享),
/// 不局限于分享场景。
static Future<Uint8List?> captureWidget({
required WidgetBuilder builder,
required BuildContext context,
double pixelRatio = 3.0,
}) async {
// 用 OverlayEntry 把 Widget 临时放到渲染树中
late OverlayEntry entry;
final boundaryKey = GlobalKey();
entry = OverlayEntry(
builder: (_) => RepaintBoundary(
key: boundaryKey,
child: builder(context),
),
);
// 插入 Overlay(Widget 开始 build)
Overlay.of(context).insert(entry);
// 等一帧让 Widget 完成渲染
await Future.delayed(const Duration(milliseconds: 100));
try {
final boundary = boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: pixelRatio);
final bytes = await image.toByteData(format: ImageByteFormat.png);
return bytes?.buffer.asUint8List();
} finally {
// 截图完成,移除临时 Overlay
entry.remove();
}
}
// ==================== 保存到相册 ====================
/// 保存图片到系统相册(Android/iOS/macOS/Windows)
///
/// 底层用 gal 插件:
/// Android → MediaStore(API 30+)或 WRITE_EXTERNAL_STORAGE(API ≤ 29)
/// iOS → PHPhotoLibrary
///
/// [filePath] 本地图片文件路径
/// [album] 保存到指定相册名(可选,Android 上不一定生效,取决于系统实现)
///
/// 返回 true 表示保存成功,false 表示失败。
/// 权限不足时会抛 GalException,调用方可 catch 处理。
static Future<bool> saveToGallery({
required String filePath,
String? album,
}) async {
if (kIsWeb) {
debugPrint('⚠ Web 端不支持保存到相册');
return false;
}
try {
await Gal.putImage(filePath, album: album);
return true;
} on GalException catch (e) {
debugPrint('⚠ 保存到相册失败:${e.type.message}');
return false;
} catch (e) {
debugPrint('⚠ 保存到相册失败:$e');
return false;
}
}
/// 保存内存字节到系统相册
///
/// 适用场景:Widget 截图后直接保存,不先写磁盘。
/// 内部自动写入临时文件再调 [saveToGallery]。
static Future<bool> saveToGalleryFromBytes({
required Uint8List bytes,
String fileName = 'save_image.jpg',
String? album,
}) async {
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/$fileName');
await file.writeAsBytes(bytes);
return saveToGallery(filePath: file.path, album: album);
}
}