refactor(editor): 将画布样式相关功能移至独立文件 fix(editor): 修复描边坐标偏移问题 feat(export): 新增applyCanvasStyle后处理方法 feat(settings): 实现CanvasStyleModel持久化存储 fix(feed): 互动操作增加登录检查避免401错误 feat(wallpaper): 实现无限下拉加载功能 fix(text): 修复富文本编辑器初始化问题
469 lines
14 KiB
Dart
469 lines
14 KiB
Dart
// ============================================================
|
|
// 闲言APP — 导出服务
|
|
// 创建时间: 2026-04-20
|
|
// 更新时间: 2026-05-05
|
|
// 作用: 截图导出 + 保存相册 + 系统分享 + WidgetLayer合成导出 + 压缩导出
|
|
// 上次更新: 新增 applyCanvasStyle 画布样式后处理(边框+阴影+叠层+圆角)
|
|
// ============================================================
|
|
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
|
import 'package:pro_image_editor/pro_image_editor.dart' as pro;
|
|
|
|
import 'package:xianyan/core/utils/logger.dart';
|
|
import 'package:xianyan/core/utils/platform_utils.dart' as pu;
|
|
import 'package:xianyan/editor/services/export/export_io_native.dart'
|
|
if (dart.library.html) 'export_io_web.dart';
|
|
import 'package:xianyan/editor/services/export/image_compress_service.dart';
|
|
import 'package:xianyan/editor/services/image/widget_layer_renderer.dart';
|
|
import 'package:pro_image_editor/core/models/canvas_style_model.dart';
|
|
|
|
class ExportService {
|
|
ExportService._();
|
|
|
|
static bool get isSaveToGallerySupported => !kIsWeb && pu.supportsFilesystem;
|
|
static bool get isShareSupported => !kIsWeb;
|
|
static bool get isFileExportSupported => !kIsWeb && pu.supportsFilesystem;
|
|
|
|
/// 从 RepaintBoundary 截图并保存到相册
|
|
static Future<bool> saveToGallery(
|
|
GlobalKey repaintKey, {
|
|
double pixelRatio = 3.0,
|
|
}) async {
|
|
if (!isSaveToGallerySupported) {
|
|
Log.w('saveToGallery: 当前平台不支持保存到相册');
|
|
return false;
|
|
}
|
|
try {
|
|
final renderObj = repaintKey.currentContext?.findRenderObject();
|
|
if (renderObj == null || renderObj is! RenderRepaintBoundary) {
|
|
Log.e('RenderObject 不是 RepaintBoundary 类型');
|
|
return false;
|
|
}
|
|
final boundary = renderObj;
|
|
|
|
final image = await boundary.toImage(pixelRatio: pixelRatio);
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
if (byteData == null) {
|
|
Log.e('图片转换失败');
|
|
return false;
|
|
}
|
|
|
|
return saveToGalleryImpl(byteData.buffer.asUint8List());
|
|
} catch (e) {
|
|
Log.e('保存到相册失败', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 从 RepaintBoundary 截图并分享
|
|
static Future<void> shareImage(
|
|
GlobalKey repaintKey, {
|
|
double pixelRatio = 3.0,
|
|
}) async {
|
|
if (!isShareSupported) {
|
|
Log.w('shareImage: 当前平台不支持分享');
|
|
return;
|
|
}
|
|
try {
|
|
final boundary =
|
|
repaintKey.currentContext?.findRenderObject()
|
|
as RenderRepaintBoundary?;
|
|
if (boundary == null) return;
|
|
|
|
final image = await boundary.toImage(pixelRatio: pixelRatio);
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
if (byteData == null) return;
|
|
|
|
await shareFileImpl(byteData.buffer.asUint8List());
|
|
} catch (e) {
|
|
Log.e('分享失败', e);
|
|
}
|
|
}
|
|
|
|
/// 高分辨率导出 (返回 PNG bytes)
|
|
static Future<Uint8List?> exportHighRes(
|
|
GlobalKey repaintKey, {
|
|
double pixelRatio = 3.0,
|
|
}) async {
|
|
try {
|
|
final boundary =
|
|
repaintKey.currentContext?.findRenderObject()
|
|
as RenderRepaintBoundary?;
|
|
if (boundary == null) return null;
|
|
|
|
final image = await boundary.toImage(pixelRatio: pixelRatio);
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
if (byteData == null) return null;
|
|
|
|
return byteData.buffer.asUint8List();
|
|
} catch (e) {
|
|
Log.e('高分辨率导出失败', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 直接从 bytes 保存到相册 (pro_image_editor 导出适配)
|
|
static Future<bool> saveBytesToGallery(Uint8List bytes) async {
|
|
if (!isSaveToGallerySupported) {
|
|
Log.w('saveBytesToGallery: 当前平台不支持保存到相册');
|
|
return false;
|
|
}
|
|
try {
|
|
return saveToGalleryImpl(bytes);
|
|
} catch (e) {
|
|
Log.e('保存到相册失败', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 直接从 bytes 分享 (pro_image_editor 导出适配)
|
|
static Future<void> shareBytes(Uint8List bytes) async {
|
|
if (!isShareSupported) {
|
|
Log.w('shareBytes: 当前平台不支持分享');
|
|
return;
|
|
}
|
|
try {
|
|
await shareFileImpl(bytes);
|
|
} catch (e) {
|
|
Log.e('分享失败', e);
|
|
}
|
|
}
|
|
|
|
/// 合成导出 — 先预渲染WidgetLayer再合成最终图片
|
|
static Future<Uint8List?> exportWithWidgetLayers({
|
|
required Uint8List baseBytes,
|
|
required List<pro.WidgetLayer> widgetLayers,
|
|
required Size canvasSize,
|
|
double pixelRatio = 3.0,
|
|
}) async {
|
|
try {
|
|
final baseImage = await decodeImageFromList(baseBytes);
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder)
|
|
..drawImage(baseImage, Offset.zero, Paint());
|
|
|
|
for (final layer in widgetLayers) {
|
|
final layerW = (layer.scale * 200).clamp(50.0, 2000.0);
|
|
final layerH = (layer.scale * 200).clamp(50.0, 2000.0);
|
|
final layerSize = Size(layerW, layerH);
|
|
|
|
final rendered = await WidgetLayerRenderer.renderLayer(
|
|
layer: layer,
|
|
layerSize: layerSize,
|
|
pixelRatio: pixelRatio,
|
|
);
|
|
if (rendered == null) continue;
|
|
|
|
final layerImage = await decodeImageFromList(rendered);
|
|
final dx = layer.offset.dx * baseImage.width - layerImage.width / 2;
|
|
final dy = layer.offset.dy * baseImage.height - layerImage.height / 2;
|
|
|
|
canvas.save();
|
|
canvas.translate(dx + layerImage.width / 2, dy + layerImage.height / 2);
|
|
canvas.rotate(layer.rotation);
|
|
canvas.drawImage(
|
|
layerImage,
|
|
Offset(-layerImage.width / 2, -layerImage.height / 2),
|
|
Paint(),
|
|
);
|
|
canvas.restore();
|
|
layerImage.dispose();
|
|
}
|
|
|
|
baseImage.dispose();
|
|
|
|
final picture = recorder.endRecording();
|
|
final finalImage = await picture.toImage(
|
|
baseImage.width,
|
|
baseImage.height,
|
|
);
|
|
final byteData = await finalImage.toByteData(
|
|
format: ui.ImageByteFormat.png,
|
|
);
|
|
finalImage.dispose();
|
|
|
|
if (byteData == null) return null;
|
|
return byteData.buffer.asUint8List();
|
|
} catch (e) {
|
|
Log.e('合成导出失败', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 压缩导出并保存到相册
|
|
static Future<bool> saveCompressedToGallery(
|
|
Uint8List pngBytes, {
|
|
int quality = 90,
|
|
CompressFormat format = CompressFormat.jpeg,
|
|
}) async {
|
|
if (!isSaveToGallerySupported) {
|
|
Log.w('saveCompressedToGallery: 当前平台不支持保存到相册');
|
|
return false;
|
|
}
|
|
try {
|
|
final compressed = await ImageCompressService.compressForExport(
|
|
pngBytes,
|
|
quality: quality,
|
|
format: format,
|
|
);
|
|
if (compressed == null) {
|
|
return saveBytesToGallery(pngBytes);
|
|
}
|
|
return saveToGalleryImpl(compressed);
|
|
} catch (e) {
|
|
Log.e('压缩保存到相册失败', e);
|
|
return saveBytesToGallery(pngBytes);
|
|
}
|
|
}
|
|
|
|
/// 压缩导出并分享
|
|
static Future<void> shareCompressed(
|
|
Uint8List pngBytes, {
|
|
int quality = 90,
|
|
CompressFormat format = CompressFormat.jpeg,
|
|
}) async {
|
|
if (!isShareSupported) {
|
|
Log.w('shareCompressed: 当前平台不支持分享');
|
|
return;
|
|
}
|
|
try {
|
|
final compressed = await ImageCompressService.compressForExport(
|
|
pngBytes,
|
|
quality: quality,
|
|
format: format,
|
|
);
|
|
final bytesToShare = compressed ?? pngBytes;
|
|
final ext = ImageCompressService.formatExt(format);
|
|
|
|
await shareCompressedFileImpl(bytesToShare, ext: ext);
|
|
} catch (e) {
|
|
Log.e('压缩分享失败', e);
|
|
await shareBytes(pngBytes);
|
|
}
|
|
}
|
|
|
|
static Future<Uint8List?> applyRoundedCorners(
|
|
Uint8List imageBytes, {
|
|
double radius = 20.0,
|
|
}) async {
|
|
try {
|
|
final codec = await ui.instantiateImageCodec(imageBytes);
|
|
final frame = await codec.getNextFrame();
|
|
final image = frame.image;
|
|
final width = image.width;
|
|
final height = image.height;
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder);
|
|
|
|
final rrect = RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
|
Radius.circular(radius * (width / 1080)),
|
|
);
|
|
|
|
canvas.clipRRect(rrect);
|
|
canvas.drawImage(image, Offset.zero, Paint());
|
|
|
|
final picture = recorder.endRecording();
|
|
final roundedImage = await picture.toImage(width, height);
|
|
final byteData = await roundedImage.toByteData(
|
|
format: ui.ImageByteFormat.png,
|
|
);
|
|
|
|
image.dispose();
|
|
roundedImage.dispose();
|
|
|
|
if (byteData == null) return null;
|
|
return byteData.buffer.asUint8List();
|
|
} catch (e) {
|
|
Log.e('圆角处理失败', e);
|
|
return imageBytes;
|
|
}
|
|
}
|
|
|
|
static Future<Uint8List?> applyCanvasStyle(
|
|
Uint8List imageBytes,
|
|
CanvasStyleModel style,
|
|
) async {
|
|
try {
|
|
final codec = await ui.instantiateImageCodec(imageBytes);
|
|
final frame = await codec.getNextFrame();
|
|
final srcImage = frame.image;
|
|
final srcW = srcImage.width.toDouble();
|
|
final srcH = srcImage.height.toDouble();
|
|
|
|
final scale = srcW / 1080.0;
|
|
final radius = style.borderRadius * scale;
|
|
final borderWidth = style.borderWidth * scale;
|
|
final shadowBlur = style.shadowBlur * scale;
|
|
final shadowSpread = style.shadowSpread * scale;
|
|
final shadowOffsetX = style.shadowOffsetX * scale;
|
|
final shadowOffsetY =
|
|
(style.shadowOffsetY != 0
|
|
? style.shadowOffsetY
|
|
: style.shadowBlur * 0.15) *
|
|
scale;
|
|
final stackDist = style.stackDistance * scale;
|
|
|
|
final hasBorder = style.hasBorder;
|
|
final hasShadow = style.hasShadow;
|
|
final hasStack = style.hasStack;
|
|
final hasMargin = style.hasOuterMargin;
|
|
|
|
if (!hasBorder && !hasShadow && !hasStack && radius <= 0 && !hasMargin) {
|
|
srcImage.dispose();
|
|
return imageBytes;
|
|
}
|
|
|
|
final autoPadding = hasShadow
|
|
? shadowBlur + shadowSpread + 8 * scale
|
|
: 0.0;
|
|
final margin = hasMargin ? style.outerMargin * scale : autoPadding;
|
|
|
|
final stackExtra = hasStack ? stackDist * style.stackLayers : 0.0;
|
|
double extraLeft = 0, extraTop = 0, extraRight = 0, extraBottom = 0;
|
|
|
|
if (hasStack) {
|
|
final pos = style.stackPosition;
|
|
if (pos == CanvasStackPosition.left) extraLeft = stackExtra;
|
|
if (pos == CanvasStackPosition.right) extraRight = stackExtra;
|
|
if (pos == CanvasStackPosition.top) extraTop = stackExtra;
|
|
if (pos == CanvasStackPosition.bottom) extraBottom = stackExtra;
|
|
}
|
|
|
|
final totalW = srcW + margin * 2 + extraLeft + extraRight;
|
|
final totalH = srcH + margin * 2 + extraTop + extraBottom;
|
|
final canvasW = totalW.toInt();
|
|
final canvasH = totalH.toInt();
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder);
|
|
|
|
if (hasMargin) {
|
|
canvas.clipRect(
|
|
Rect.fromLTWH(0, 0, canvasW.toDouble(), canvasH.toDouble()),
|
|
);
|
|
}
|
|
|
|
final imgX = margin + extraLeft;
|
|
final imgY = margin + extraTop;
|
|
final imgRect = Rect.fromLTWH(imgX, imgY, srcW, srcH);
|
|
final imgRRect = RRect.fromRectAndRadius(
|
|
imgRect,
|
|
Radius.circular(radius),
|
|
);
|
|
|
|
if (hasStack) {
|
|
for (int i = style.stackLayers; i >= 1; i--) {
|
|
double dx = 0, dy = 0;
|
|
final offset = stackDist * i;
|
|
if (style.stackPosition == CanvasStackPosition.left) dx = -offset;
|
|
if (style.stackPosition == CanvasStackPosition.right) dx = offset;
|
|
if (style.stackPosition == CanvasStackPosition.top) dy = -offset;
|
|
if (style.stackPosition == CanvasStackPosition.bottom) dy = offset;
|
|
|
|
final layerRect = imgRect.translate(dx, dy);
|
|
final layerRRect = RRect.fromRectAndRadius(
|
|
layerRect,
|
|
Radius.circular(radius),
|
|
);
|
|
|
|
canvas.drawRRect(
|
|
layerRRect,
|
|
Paint()..color = const Color(0x99FFFFFF),
|
|
);
|
|
|
|
if (hasBorder) {
|
|
canvas.drawRRect(
|
|
layerRRect,
|
|
Paint()
|
|
..color = style.borderColor.withValues(alpha: 0.2)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = borderWidth * 0.5,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasShadow) {
|
|
final shadowRRect = imgRRect
|
|
.shift(Offset(shadowOffsetX, shadowOffsetY))
|
|
.inflate(shadowSpread);
|
|
canvas.drawRRect(
|
|
shadowRRect,
|
|
Paint()
|
|
..color = Colors.black.withValues(alpha: style.shadowOpacity)
|
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur),
|
|
);
|
|
}
|
|
|
|
canvas.save();
|
|
canvas.clipRRect(imgRRect);
|
|
canvas.drawImage(srcImage, Offset(imgX, imgY), Paint());
|
|
canvas.restore();
|
|
|
|
if (hasBorder) {
|
|
if (style.borderStyle == CanvasBorderStyle.dashed) {
|
|
_drawDashedRRect(canvas, imgRRect, style.borderColor, borderWidth);
|
|
} else {
|
|
canvas.drawRRect(
|
|
imgRRect,
|
|
Paint()
|
|
..color = style.borderColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = borderWidth,
|
|
);
|
|
}
|
|
}
|
|
|
|
final picture = recorder.endRecording();
|
|
final resultImage = await picture.toImage(canvasW, canvasH);
|
|
final byteData = await resultImage.toByteData(
|
|
format: ui.ImageByteFormat.png,
|
|
);
|
|
|
|
srcImage.dispose();
|
|
resultImage.dispose();
|
|
|
|
if (byteData == null) return null;
|
|
return byteData.buffer.asUint8List();
|
|
} catch (e) {
|
|
Log.e('画布样式处理失败', e);
|
|
return imageBytes;
|
|
}
|
|
}
|
|
|
|
static void _drawDashedRRect(
|
|
Canvas canvas,
|
|
RRect rrect,
|
|
Color color,
|
|
double strokeWidth,
|
|
) {
|
|
final paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = strokeWidth;
|
|
|
|
const dashWidth = 8.0;
|
|
const dashGap = 5.0;
|
|
final path = Path()..addRRect(rrect);
|
|
|
|
final metrics = path.computeMetrics();
|
|
for (final metric in metrics) {
|
|
double distance = 0;
|
|
while (distance < metric.length) {
|
|
final end = (distance + dashWidth).clamp(0.0, metric.length);
|
|
canvas.drawPath(metric.extractPath(distance, end), paint);
|
|
distance += dashWidth + dashGap;
|
|
}
|
|
}
|
|
}
|
|
}
|