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