Files
2026-09-04 15:22:20 +08:00

226 lines
7.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}