226 lines
7.4 KiB
Dart
226 lines
7.4 KiB
Dart
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);
|
||
}
|
||
}
|