feat: 新增画布样式后处理功能及导出一致性优化

refactor(editor): 将画布样式相关功能移至独立文件
fix(editor): 修复描边坐标偏移问题
feat(export): 新增applyCanvasStyle后处理方法
feat(settings): 实现CanvasStyleModel持久化存储
fix(feed): 互动操作增加登录检查避免401错误
feat(wallpaper): 实现无限下拉加载功能
fix(text): 修复富文本编辑器初始化问题
This commit is contained in:
Developer
2026-05-05 08:24:28 +08:00
parent b5157c19f4
commit b11d53ca58
14 changed files with 1956 additions and 194 deletions

View File

@@ -16,7 +16,7 @@ api接口部分可在本地使用接口请求验证确保接口正常响
你现在是苹果前端工程师,这个项目经过多人之手,不同的人设计略有差异,
请设计风格跟苹果集团一体的页面,如果风格不一致我就换其他 ai 了
软件风格需要图文并茂尽量使用icon若无icon则使用通用的emoji代替
软件风格需要图文并茂尽量使用icon若无icon则使用本地的svg代替若无svg则自绘或使用通用的emoji代替
https://developer.apple.com/design/human-interface-guidelines
软件要求风格统一,如颜色圆角按钮显示,每次修改页面需按照已经规定的值进行

View File

@@ -4,6 +4,163 @@
---
## [3.9.13] - 2026-05-05
### ✨ 新功能 — 画布外边距控制
1. **外边距outerMargin**`CanvasStyleModel` 新增 `outerMargin` 字段(-1=自动0=紧贴1-60=自定义px画布样式面板新增"外边距"区域,含预设(自动/紧贴/适中/宽松)+ 滑块
2. **强制压缩效果** — 当 `outerMargin` 为 0 时,阴影/叠层等效果被裁剪到画布边缘内;值越大,效果可扩展空间越大;值越小,效果被强制压缩
### 📁 修改文件
- `packages/pro_image_editor/lib/core/models/canvas_style_model.dart` — 新增 outerMargin + hasOuterMargin
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — _CanvasStyleWrapper 用 Padding+ClipRect+OverflowBox 限制效果
- `lib/editor/services/export/export_service.dart` — applyCanvasStyle 使用 outerMargin 作为固定 margin + clipRect 裁剪
- `lib/editor/mixins/editor_export_actions.dart` — 导出条件增加 hasOuterMargin
- `lib/editor/widgets/panels/canvas_style_sheet.dart` — 新增外边距区域(预设+滑块)
---
## [3.9.12] - 2026-05-05
### ✨ 新功能 — 阴影扩散范围调节
1. **阴影扩散范围**`CanvasStyleModel` 新增 `shadowSpread` 字段0-30px画布样式面板阴影区域新增"扩散"滑块,可调整阴影的上下高度/扩散面积
### 📁 修改文件
- `packages/pro_image_editor/lib/core/models/canvas_style_model.dart` — 新增 shadowSpread 字段
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — _CanvasStyleWrapper 使用 shadowSpread
- `lib/editor/services/export/export_service.dart` — applyCanvasStyle 阴影 RRect.inflate(shadowSpread)
- `lib/editor/widgets/panels/canvas_style_sheet.dart` — 阴影扩散滑块 + 预览区 spreadRadius
---
## [3.9.11] - 2026-05-05
### 🐛 Bug修复 — 导出画布样式一致性 + 阴影偏移调节
1. **导出效果与编辑预览完全一致** — 不再依赖 widget 渲染导出效果,改为 `dart:ui` Canvas 后处理:在 `ExportService.applyCanvasStyle` 中用 `PictureRecorder` 绘制圆角、边框、阴影、叠层,确保导出图片与编辑预览 1:1 一致
2. **阴影偏移量调节**`CanvasStyleModel` 新增 `shadowOffsetX` / `shadowOffsetY` 字段,画布样式面板新增 ↔↕ 偏移滑块 + 方向按钮(左/右/上/下/重置),可精确控制阴影方向和距离
### 📁 修改文件
- `packages/pro_image_editor/lib/core/models/canvas_style_model.dart` — 新增 shadowOffsetX/Y 字段 + copyWith/toJson/fromJson
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — _CanvasStyleWrapper 使用 shadowOffsetX/Y
- `lib/editor/services/export/export_service.dart` — 新增 applyCanvasStyle + _drawDashedRRect 后处理方法
- `lib/editor/mixins/editor_export_actions.dart` — onEditingComplete 中调用 applyCanvasStyle 后处理
- `lib/editor/mixins/editor_actions_base.dart` — 新增 canvasStyle 抽象 getter
- `lib/editor/pages/editor/pro_editor_page.dart` — 实现 canvasStyle getter
- `lib/editor/widgets/panels/canvas_style_sheet.dart` — 阴影偏移滑块+方向按钮+预览区偏移
---
## [3.9.10] - 2026-05-05
### 🐛 Bug修复 — 画布样式渲染+颜色选择器崩溃
1. **导出效果与编辑预览一致** — 重构 `_CanvasStyleWrapper` 渲染顺序child → 边框(画布边缘上) → 阴影(最外层),所有效果在 ContentRecorder 内部渲染,导出图片与编辑预览完全一致
2. **阴影区域过大修复**`spreadRadius: 0`之前1`offset` 改为按 blurRadius 比例计算,预设值从 15/25 降至 8/16
3. **虚线边框未显示修复**`_DashedBorderPainter``paint` 方法使用正确的 `Size` 参数,并考虑 `borderWidth` 内缩偏移,虚线现在正确绘制在画布边缘上
4. **边框位置修复** — 边框现在套在画布上(内层),阴影在最外层,不再出现边框在阴影外面的情况
5. **颜色选择器崩溃修复**`ColorPicker``CupertinoAlertDialog.title` 移至 `showModalBottomSheet`,避免复杂 widget 在受限空间中布局溢出导致卡死
### 📁 修改文件
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — 重构 _CanvasStyleWrapper + _BorderPainter + _DashedBorderPainter
- `lib/editor/widgets/panels/canvas_style_sheet.dart` — 颜色选择器改用 showModalBottomSheet + 预览区渲染顺序修正 + 阴影参数调优
---
## [3.9.9] - 2026-05-05
### ✨ 新功能 — 画布样式编辑面板(独立文件,后续扩展)
1. **画布样式编辑面板** — 新增独立 `CanvasStyleSheet` 文件,支持以下编辑项目:
- 🎨 边缘边框:线条粗细、实线/虚线切换、线条颜色选择
- 🌫️ 边缘外侧阴影:模糊半径、透明度浓淡调节
- 📚 边缘外侧叠层效果:多层层数、叠层距离
- 📍 叠层位置:下方/上方/左侧/右侧/居中
2. **CanvasStyleModel 数据模型** — 统一管理圆角+边框+阴影+叠层所有属性,支持 `copyWith`/`toJson`/`fromJson` 序列化
3. **编辑器实时渲染** — 边框、阴影、叠层效果在编辑器中实时显示,虚线边框使用 `CustomPainter` 绘制
4. **导出效果一致** — 所有样式效果在 `ContentRecorder` 内部渲染,导出图片与编辑预览完全一致
5. **画布样式按钮** — 顶部导航栏右侧胶囊新增 `rectangle` 图标按钮,点击打开画布样式编辑面板
6. **EditorSettingsSheet 精简** — 画布圆角/边框/阴影/叠层从设置面板移至独立 CanvasStyleSheet
### 📁 修改文件
- `lib/editor/widgets/panels/canvas_style_sheet.dart` — 新建:画布样式编辑面板(独立文件)
- `packages/pro_image_editor/lib/core/models/canvas_style_model.dart` — 新建:画布样式数据模型
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — _CanvasStyleWrapper + _DashedBorderPainter 渲染边框/阴影/叠层
- `packages/pro_image_editor/lib/core/models/styles/main_editor_style.dart` — 新增 canvasStyle 属性
- `lib/editor/widgets/controls/editor_top_nav.dart` — 新增 onCanvasStyle 回调 + rectangle 按钮
- `lib/editor/pages/editor/pro_editor_page.dart` — CanvasStyleModel 状态 + _showCanvasStyleEditor
- `lib/editor/services/core/pro_editor_bridge.dart` — buildConfigs 接受 CanvasStyleModel
- `lib/editor/services/core/editor_settings_service.dart` — loadCanvasStyle/saveCanvasStyle
- `lib/editor/widgets/panels/editor_settings_sheet.dart` — 精简:移除画布圆角部分
---
## [3.9.8] - 2026-05-05
### 🐛 Bug修复 + ✨ 新功能
1. **拖拽描边坐标偏移修复** — 之前 `localToGlobal(ancestor: overlayBox)` 在 overlayBox 不是 layer 祖先时返回全局坐标而非 overlay 本地坐标,导致描边偏右下。改为 `layerBox.localToGlobal()``overlayBox.globalToLocal()` 两步转换,正确计算 overlay 本地坐标
2. **文本编辑器内容回写修复** — 点击工具栏文本按钮时 `editor.selectedLayer` 可能已被清空,改为优先取 `selectedLayerNotifier` 保留的上一次选中 TextLayer 引用,确保输入框显示已有文本
3. **在线壁纸无限下拉加载** — "全部"源模式之前 `_hasMore = false` 无法继续加载改为按页码轮询12个源分页加载 + `_loadedIds` 去重 + `anyHasNext` 判断是否还有更多
4. **顶部工具栏工具抽屉按钮** — 右侧胶囊新增 `sidebar_left` 图标按钮,点击呼出工具抽屉(编辑/内容/更多三组工具)
5. **画布圆角动态调整** — 编辑器设置面板新增画布圆角滑块0~80px+ 4个预设直角/小圆角/圆角/大圆角),圆角实时生效并持久化到 SharedPreferences导出图片自动带圆角
### 📁 修改文件
- `lib/editor/pages/editor/pro_editor_page.dart` — 描边坐标两步转换 + selectedLayerNotifier回退 + canvasBorderRadius状态 + onToolDrawer
- `lib/editor/widgets/controls/editor_top_nav.dart` — 新增 onToolDrawer 回调 + sidebar_left 按钮
- `lib/editor/widgets/panels/editor_settings_sheet.dart` — 新增画布圆角滑块+预设StatefulWidget化
- `lib/editor/services/core/editor_settings_service.dart` — 新增 loadCanvasRadius/saveCanvasRadius
- `lib/editor/services/core/pro_editor_bridge.dart` — buildConfigs 接受动态 canvasBorderRadius
- `lib/shared/widgets/wallpaper_gallery/wallpaper_gallery_view.dart` — 全部源分页加载+去重+无限下拉
---
## [3.9.7] - 2026-05-05
### 🐛 Bug修复画布圆角显示+导出修复+401修复
1. **画布圆角显示修复** — 之前 `ClipRRect` 裁剪的是整个 `ContentRecorder` 区域(包含图片周围空白),圆角只在空白区域可见,图片本身没有圆角。改为 `Center` + `SizedBox(decodedImageSize)` + `ClipRRect` 限制裁剪区域为图片实际大小,使圆角正确显示在图片四角
2. **导出图片圆角修复**`ClipRRect``ContentRecorder` 内部,`ExtendedRepaintBoundary.toImage()` 截图时已包含圆角裁剪效果导出图片自动带圆角PNG 透明/JPEG 白色填充)。新增 `ExportService.applyRoundedCorners()` 备用方法
3. **文字编辑内容回写** — 点击文本按钮时,输入框显示编辑器中已有文本;点击完成后,编辑器显示编辑后的文本
4. **拖拽描边修复**`_overlayStackKey` 仅在拖拽时存在导致 null context改为 overlay Stack 始终存在、条件渲染 DragBorderOverlay
5. **Feed互动401修复** — 未登录用户执行 like/favorite/share 等互动操作时API 返回 401。在 `FeedService.action` 中增加登录检查,未登录时跳过 API 请求直接返回 falseview 操作除外)
### 📁 修改文件
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — Center+SizedBox+ClipRRect 限制裁剪区域
- `packages/pro_image_editor/lib/core/models/styles/main_editor_style.dart` — canvasBorderRadius 属性
- `lib/editor/services/export/export_service.dart` — 新增 applyRoundedCorners 备用方法
- `lib/editor/mixins/editor_export_actions.dart` — 移除双重圆角调用
- `lib/editor/pages/editor/pro_editor_page.dart` — 文本编辑内容回写 + DragBorderOverlay 修复
- `lib/features/home/services/feed_service.dart` — action方法增加登录检查避免401
---
## [3.9.6] - 2026-05-05
### 🐛 Bug修复编辑器三大问题修复
1. **画布圆角修复(最终版)** — 圆角之前加在了画布下层的白色背景区域,画布本身没有圆角。在 `MainEditorStyle` 新增 `canvasBorderRadius` 属性,在 `ContentRecorder` 内部用 `ClipRRect` 裁剪画布内容(图像+图层),背景层保持无圆角
2. **文字按钮卡死闪退(根因修复)**`flutter_quill``raw_editor_state.dart``renderEditor` getter 使用 `_editorKey.currentContext!` 空断言,当 widget 未挂载时崩溃。改为 null 检查 + `StateError``_updateOrDisposeSelectionOverlayIfNeeded`/`_showCaretOnScreen`/`contextMenuAnchors`/`_getGlyphHeights` 增加 `currentContext == null` 提前返回保护;`RichTextEditorPanel` 添加 `Material` 祖先部件(解决 `QuillSimpleToolbar``IconButton`/`Theme.of` 需要 Material 祖先);`autoFocus` 改为延迟生效(`_editorReady` 标志),避免 widget 未构建完成时触发焦点请求
3. **描述线条位置不一致**`DragBorderOverlay` 的坐标使用 `renderBox.localToGlobal(Offset.zero)` 返回屏幕全局坐标,但 overlay 放在 `bodyItems``Stack` 中,坐标系受外层 `Padding`/`SafeArea`/`Scaffold` 偏移影响。改为 `localToGlobal(Offset.zero, ancestor: overlayBox)` 将坐标转换为 overlay Stack 的本地坐标,消除偏移
### 📁 修改文件
- `packages/pro_image_editor/lib/core/models/styles/main_editor_style.dart` — 新增 `canvasBorderRadius`
- `packages/pro_image_editor/lib/features/main_editor/widgets/main_editor_interactive_content.dart` — ContentRecorder 内部 ClipRRect
- `packages/flutter_quill/lib/src/editor/raw_editor/raw_editor_state.dart` — renderEditor 空安全加固
- `lib/editor/widgets/panels/rich_text_editor_panel.dart` — Material 祖先 + 延迟 autoFocus
- `lib/editor/pages/editor/pro_editor_page.dart` — DragBorderOverlay 坐标转换修复
---
## [3.9.5] - 2026-05-05
### 🐛 Bug修复回归修复 Round 4

View File

@@ -1,4 +1,4 @@
// ============================================================
// ============================================================
// 闲言APP — 编辑器操作基础 Mixin
// 创建时间: 2026-04-25
// 更新时间: 2026-04-25
@@ -14,6 +14,7 @@ import 'package:pro_image_editor/pro_image_editor.dart' as pro;
import 'package:xianyan/editor/services/core/layer_manager_service.dart';
import 'package:xianyan/editor/widgets/editors/gradient_editor.dart';
import 'package:xianyan/editor/pages/editor/pro_editor_page.dart';
import 'package:pro_image_editor/core/models/canvas_style_model.dart';
mixin EditorActionsBase on State<ProEditorPage> {
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
@@ -33,6 +34,7 @@ mixin EditorActionsBase on State<ProEditorPage> {
GlobalKey get repaintKey;
List<double> get snapGuidesX;
List<double> get snapGuidesY;
CanvasStyleModel get canvasStyle;
set snapGuidesX(List<double> v);
set snapGuidesY(List<double> v);

View File

@@ -3,7 +3,7 @@
// 创建时间: 2026-04-25
// 更新时间: 2026-04-25
// 作用: 导出相关操作 — 保存/分享/GIF/动态照片/预览/裁剪/信息
// 上次更新: 格式/质量选择生效 + 压缩导出 + 空指针防护 + 导出防重复
// 上次更新: 导出前applyCanvasStyle后处理+格式/质量选择生效+压缩导出+空指针防护+导出防重复
// ============================================================
import 'dart:typed_data';
@@ -295,21 +295,32 @@ mixin EditorExportActions on EditorActionsBase {
);
try {
Uint8List exportBytes = bytes;
final style = canvasStyle;
if (style.hasBorder ||
style.hasShadow ||
style.hasStack ||
style.borderRadius > 0 ||
style.hasOuterMargin) {
final styled = await ExportService.applyCanvasStyle(bytes, style);
if (styled != null) exportBytes = styled;
}
if (isShareMode) {
await ExportService.shareCompressed(
bytes,
exportBytes,
quality: config.quality,
format: config.compressFormat,
);
if (mounted) showExportResult(success: true, bytes: bytes);
if (mounted) showExportResult(success: true, bytes: exportBytes);
} else {
final result = await ExportService.saveCompressedToGallery(
bytes,
exportBytes,
quality: config.quality,
format: config.compressFormat,
);
actionsLog.i('saveCompressedToGallery result: $result');
if (mounted) showExportResult(success: result, bytes: bytes);
if (mounted) showExportResult(success: result, bytes: exportBytes);
}
} catch (e, stack) {
actionsLog.e('Export failed: $e\n$stack');

View File

@@ -3,9 +3,10 @@
// 创建时间: 2026-04-23
// 更新时间: 2026-05-05
// 作用: ProImageEditor 的闲言APP包装页全新iOS 26风格
// 上次更新: 修复画布ClipRRect圆角+缩进修复+ProImageEditor参数对齐
// 上次更新: 修复描边坐标偏移(globalToLocal两步转换)+文本编辑器用selectedLayerNotifier回退取TextLayer
// ============================================================
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
@@ -26,11 +27,13 @@ import 'package:xianyan/editor/widgets/controls/editor_drawer_sheet.dart';
import 'package:xianyan/editor/widgets/controls/snap_guide_overlay.dart';
import 'package:xianyan/editor/widgets/editors/eye_dropper.dart';
import 'package:xianyan/editor/widgets/editors/emoji_icon_grid.dart';
import 'package:xianyan/editor/models/editor_models.dart'
show StickerType, DragBorderStyle;
import 'package:xianyan/editor/models/editor_models.dart' show DragBorderStyle;
import 'package:xianyan/editor/services/core/editor_settings_service.dart';
import 'package:xianyan/editor/widgets/controls/drag_border_overlay.dart';
import 'package:xianyan/editor/widgets/panels/editor_settings_sheet.dart';
import 'package:xianyan/editor/widgets/panels/canvas_style_sheet.dart';
import 'package:pro_image_editor/core/models/canvas_style_model.dart'
show CanvasStyleModel;
import 'package:xianyan/editor/widgets/panels/rich_text_editor_panel.dart';
import 'package:xianyan/shared/widgets/wallpaper_gallery/wallpaper_gallery.dart';
import 'package:xianyan/editor/mixins/editor_actions_base.dart';
@@ -82,8 +85,10 @@ class ProEditorPageState extends State<ProEditorPage>
bool _showHistorySlider = true;
bool _showThemePanel = false;
DragBorderStyle _dragBorderStyle = DragBorderStyle.dashed;
CanvasStyleModel _canvasStyle = CanvasStyleModel.defaults;
bool _isLayerDragging = false;
Rect? _draggingLayerRect;
final _overlayStackKey = GlobalKey();
static const double _zoomHideThreshold = 1.05;
final ValueNotifier<double> _zoomScaleNotifier = ValueNotifier(1.0);
@@ -91,6 +96,8 @@ class ProEditorPageState extends State<ProEditorPage>
@override
GlobalKey<pro.ProImageEditorState> get editorKey => _editorKey;
@override
CanvasStyleModel get canvasStyle => _canvasStyle;
@override
GlobalKey get repaintKey => _repaintKey;
@@ -133,7 +140,12 @@ class ProEditorPageState extends State<ProEditorPage>
void _loadSettings() async {
final style = await EditorSettingsService.loadBorderStyle();
if (mounted) setState(() => _dragBorderStyle = style);
final canvasStyle = await EditorSettingsService.loadCanvasStyle();
if (mounted)
setState(() {
_dragBorderStyle = style;
_canvasStyle = canvasStyle;
});
}
void _scheduleInitialText() {
@@ -332,15 +344,54 @@ class ProEditorPageState extends State<ProEditorPage>
}
void _showRichTextEditor() {
final editor = _editorKey.currentState;
final currentSelected = editor?.selectedLayer;
final notifierLayer = selectedLayerNotifier.value;
pro.TextLayer? textLayer;
if (currentSelected is pro.TextLayer) {
textLayer = currentSelected;
} else if (notifierLayer is pro.TextLayer) {
textLayer = notifierLayer;
}
String? initialDeltaJson;
if (textLayer != null) {
final plainText = textLayer.text;
if (plainText.isNotEmpty) {
initialDeltaJson = jsonEncode([
{'insert': plainText},
]);
}
}
showCupertinoModalPopup<void>(
context: context,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: RichTextEditorPanel(
onDone: () => Navigator.pop(context),
initialDeltaJson: initialDeltaJson,
onDone: () {
Navigator.pop(context);
},
onChanged: (deltaJson) {
final editor = _editorKey.currentState;
if (editor == null) return;
if (textLayer == null) return;
final editorState = _editorKey.currentState;
if (editorState == null) return;
try {
final deltaOps = jsonDecode(deltaJson) as List<dynamic>;
final plainText = deltaOps
.whereType<Map<String, dynamic>>()
.where((op) => op.containsKey('insert'))
.map((op) {
final v = op['insert'];
return v is String ? v : '';
})
.join();
if (plainText.trim().isEmpty) return;
textLayer.text = plainText;
editorState.setState(() {});
} catch (_) {}
},
),
),
@@ -355,13 +406,24 @@ class ProEditorPageState extends State<ProEditorPage>
EditorSettingsSheet.show(
context: context,
borderStyle: _dragBorderStyle,
onBorderStyleChanged: (style) {
onBorderStyleChanged: (DragBorderStyle style) {
setState(() => _dragBorderStyle = style);
EditorSettingsService.saveBorderStyle(style);
},
);
}
void _showCanvasStyleEditor() {
CanvasStyleSheet.show(
context: context,
style: _canvasStyle,
onChanged: (CanvasStyleModel newStyle) {
setState(() => _canvasStyle = newStyle);
EditorSettingsService.saveCanvasStyle(newStyle);
},
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -372,6 +434,7 @@ class ProEditorPageState extends State<ProEditorPage>
final safeBottom = MediaQuery.of(context).viewPadding.bottom;
final configs = ProEditorBridge.buildConfigs(
context: context,
canvasStyle: _canvasStyle,
bodyItemsBuilder: (editor, stream) =>
_buildMainBodyWidgets(editor, stream, safeTop, safeBottom),
onCloseWarning: handleCloseWarning,
@@ -388,79 +451,96 @@ class ProEditorPageState extends State<ProEditorPage>
child: SizedBox.expand(
child: Padding(
padding: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: RepaintBoundary(
key: _repaintKey,
child: pro.ProImageEditor.memory(
widget.imageBytes,
key: _editorKey,
configs: configs,
callbacks: pro.ProImageEditorCallbacks(
onImageEditingComplete: onEditingComplete,
onCloseEditor: (mode) => Navigator.of(context).pop(),
mainEditorCallbacks: pro.MainEditorCallbacks(
onCreateTextLayer:
widget.initialText != null &&
widget.initialText!.isNotEmpty
? () async => pro.TextLayer(
text: widget.initialText!,
color: const Color(0xFFFFFFFF),
background: Colors.transparent,
align: TextAlign.center,
offset: const Offset(0.5, 0.3),
)
: null,
onLayerTapUp: (layer) => _onLayerTapUp(layer),
onScaleStart: (_) {
if (!_isLayerDragging) {
setState(() => _isLayerDragging = true);
}
},
onScaleUpdate: (details) {
final editorState = _editorKey.currentState;
final layer = editorState?.selectedLayer;
if (layer != null) {
final renderBox =
layer.key.currentContext?.findRenderObject()
as RenderBox?;
if (renderBox != null && renderBox.hasSize) {
final size = renderBox.size;
final offset = renderBox.localToGlobal(
Offset.zero,
);
setState(() {
_draggingLayerRect = offset & size;
});
}
}
},
onScaleEnd: (_) {
if (_isLayerDragging) {
child: RepaintBoundary(
key: _repaintKey,
child: pro.ProImageEditor.memory(
widget.imageBytes,
key: _editorKey,
configs: configs,
callbacks: pro.ProImageEditorCallbacks(
onImageEditingComplete: onEditingComplete,
onCloseEditor: (mode) => Navigator.of(context).pop(),
mainEditorCallbacks: pro.MainEditorCallbacks(
onCreateTextLayer:
widget.initialText != null &&
widget.initialText!.isNotEmpty
? () async => pro.TextLayer(
text: widget.initialText!,
color: const Color(0xFFFFFFFF),
background: Colors.transparent,
align: TextAlign.center,
offset: const Offset(0.5, 0.3),
)
: null,
onLayerTapUp: (layer) => _onLayerTapUp(layer),
onScaleStart: (_) {
if (!_isLayerDragging) {
setState(() => _isLayerDragging = true);
}
},
onScaleUpdate: (details) {
final editorState = _editorKey.currentState;
final layer = editorState?.selectedLayer;
if (layer != null) {
final layerBox =
layer.key.currentContext?.findRenderObject()
as RenderBox?;
final overlayBox =
_overlayStackKey.currentContext
?.findRenderObject()
as RenderBox?;
if (layerBox != null &&
layerBox.hasSize &&
overlayBox != null) {
final globalTopLeft = layerBox.localToGlobal(
Offset.zero,
);
final globalBottomRight = layerBox.localToGlobal(
Offset(
layerBox.size.width,
layerBox.size.height,
),
);
final localTopLeft = overlayBox.globalToLocal(
globalTopLeft,
);
final localBottomRight = overlayBox.globalToLocal(
globalBottomRight,
);
final size = Size(
(localBottomRight.dx - localTopLeft.dx).abs(),
(localBottomRight.dy - localTopLeft.dy).abs(),
);
setState(() {
_isLayerDragging = false;
_draggingLayerRect = null;
_draggingLayerRect = localTopLeft & size;
});
}
},
onLongPress: () {
EditorThemeNotifier.instance.toggleDarkLight();
},
onEditorZoomScaleStart: (_) {
if (!_isPinching) {
setState(() => _isPinching = true);
}
},
onEditorZoomScaleEnd: (_) {
if (_isPinching) {
setState(() => _isPinching = false);
}
},
onEditorZoomMatrix4Change: (matrix) {
_zoomScaleNotifier.value = matrix
.getMaxScaleOnAxis();
},
),
}
},
onScaleEnd: (_) {
if (_isLayerDragging) {
setState(() {
_isLayerDragging = false;
_draggingLayerRect = null;
});
}
},
onLongPress: () {
EditorThemeNotifier.instance.toggleDarkLight();
},
onEditorZoomScaleStart: (_) {
if (!_isPinching) {
setState(() => _isPinching = true);
}
},
onEditorZoomScaleEnd: (_) {
if (_isPinching) {
setState(() => _isPinching = false);
}
},
onEditorZoomMatrix4Change: (matrix) {
_zoomScaleNotifier.value = matrix.getMaxScaleOnAxis();
},
),
),
),
@@ -511,6 +591,8 @@ class ProEditorPageState extends State<ProEditorPage>
onThemeToggle: _toggleThemePanel,
onExport: () => handleExport(),
onSettings: _showEditorSettings,
onToolDrawer: _showToolDrawer,
onCanvasStyle: _showCanvasStyleEditor,
),
),
),
@@ -544,20 +626,21 @@ class ProEditorPageState extends State<ProEditorPage>
canvasSize: MediaQuery.of(context).size,
),
),
if (_isLayerDragging && _draggingLayerRect != null)
pro.ReactiveWidget(
stream: rebuildStream,
builder: (_) => SizedBox.expand(
child: Stack(
children: [
pro.ReactiveWidget(
stream: rebuildStream,
builder: (_) => SizedBox.expand(
key: _overlayStackKey,
child: Stack(
children: [
if (_isLayerDragging && _draggingLayerRect != null)
DragBorderOverlay(
rect: _draggingLayerRect!,
style: _dragBorderStyle,
),
],
),
],
),
),
),
if (!editor.isSubEditorOpen)
pro.ReactiveWidget(
stream: rebuildStream,

View File

@@ -1,18 +1,22 @@
// ============================================================
// 闲言APP — 编辑器设置持久化服务
// 创建时间: 2026-05-04
// 更新时间: 2026-05-04
// 更新时间: 2026-05-05
// 作用: 编辑器偏好设置持久化存储
// 上次更新: 初始创建 — 描边风格偏好
// 上次更新: CanvasStyleModel整体持久化(替换单独canvasRadius)
// ============================================================
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pro_image_editor/core/models/canvas_style_model.dart';
import 'package:xianyan/editor/models/editor_models.dart';
class EditorSettingsService {
EditorSettingsService._();
static const _borderStyleKey = 'editor_drag_border_style';
static const _canvasStyleKey = 'editor_canvas_style';
static SharedPreferences? _prefs;
@@ -35,4 +39,21 @@ class EditorSettingsService {
final prefs = await _instance;
await prefs.setString(_borderStyleKey, style.name);
}
static Future<CanvasStyleModel> loadCanvasStyle() async {
final prefs = await _instance;
final saved = prefs.getString(_canvasStyleKey);
if (saved == null) return CanvasStyleModel.defaults;
try {
final json = jsonDecode(saved) as Map<String, dynamic>;
return CanvasStyleModel.fromJson(json);
} catch (_) {
return CanvasStyleModel.defaults;
}
}
static Future<void> saveCanvasStyle(CanvasStyleModel style) async {
final prefs = await _instance;
await prefs.setString(_canvasStyleKey, jsonEncode(style.toJson()));
}
}

View File

@@ -2,7 +2,7 @@
// 闲言APP — ProImageEditor 桥接层
// 创建时间: 2026-04-23
// 更新时间: 2026-05-05
// 上次更新: MainEditorStyle.background用palette.bgCanvas + wrapBody去冗余ClipRRect圆角由外层统一裁剪
// 上次更新: buildConfigs接受CanvasStyleModel(圆角+边框+阴影+叠层)
// ============================================================
import 'dart:ui' as ui;
@@ -17,6 +17,7 @@ import 'package:pro_image_editor/designs/frosted_glass/frosted_glass.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/editor/models/editor_models.dart';
import 'package:pro_image_editor/core/models/canvas_style_model.dart';
import 'package:xianyan/editor/services/core/editor_theme_notifier.dart';
import 'package:xianyan/editor/services/core/sticker_config_builder.dart';
import 'package:xianyan/editor/services/core/widget_layer_factory.dart';
@@ -101,6 +102,7 @@ class ProEditorBridge {
/// 构建 ProImageEditorConfigs — Cupertino + iOS风格 + 中文
static pro.ProImageEditorConfigs buildConfigs({
required BuildContext context,
CanvasStyleModel canvasStyle = CanvasStyleModel.defaults,
List<TextStyle>? additionalFonts,
List<pro.ReactiveWidget> Function(
pro.ProImageEditorState editor,
@@ -170,6 +172,8 @@ class ProEditorBridge {
),
style: pro.MainEditorStyle(
background: EditorThemeNotifier.instance.palette.bgCanvas,
canvasBorderRadius: BorderRadius.circular(canvasStyle.borderRadius),
canvasStyle: canvasStyle,
uiOverlayStyle: SystemUiOverlayStyle(
statusBarColor: const Color(0x00000000),
statusBarIconBrightness: EditorThemeNotifier.instance.isDark

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 导出服务
// 创建时间: 2026-04-20
// 更新时间: 2026-04-25
// 更新时间: 2026-05-05
// 作用: 截图导出 + 保存相册 + 系统分享 + WidgetLayer合成导出 + 压缩导出
// 上次更新: 跨平台兼容 — 条件导入隔离dart:io
// 上次更新: 新增 applyCanvasStyle 画布样式后处理(边框+阴影+叠层+圆角)
// ============================================================
import 'dart:typed_data';
@@ -21,6 +21,7 @@ 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._();
@@ -247,4 +248,221 @@ class ExportService {
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;
}
}
}
}

View File

@@ -1,9 +1,11 @@
// ============================================================
// 闲言APP — 编辑器顶部导航栏
// 创建时间: 2026-05-03
// 更新时间: 2026-05-05
// 作用: 3个浮动胶囊(左:关闭/保存/撤销/重做 中:图片信息 右:主题/导出)
// 上次更新: 竖屏右栏换行第二行 + 横屏单行布局
// 作用: 3个浮动胶囊(左:关闭/保存/撤销/重做 中:图片信息 右:工具抽屉/主题/导出)
// 上次更新: 右侧胶囊增加工具抽屉按钮(onToolDrawer)
// ============================================================
import 'dart:typed_data';
@@ -31,6 +33,8 @@ class EditorTopNav extends StatefulWidget {
required this.onThemeToggle,
required this.onExport,
this.onSettings,
this.onToolDrawer,
this.onCanvasStyle,
});
final pro.ProImageEditorState editor;
@@ -43,6 +47,8 @@ class EditorTopNav extends StatefulWidget {
final VoidCallback onThemeToggle;
final VoidCallback onExport;
final VoidCallback? onSettings;
final VoidCallback? onToolDrawer;
final VoidCallback? onCanvasStyle;
@override
State<EditorTopNav> createState() => _EditorTopNavState();
@@ -72,9 +78,7 @@ class _EditorTopNavState extends State<EditorTopNav> {
padding: EdgeInsets.only(top: widget.safeTop + 4, left: 12, right: 12),
child: Align(
alignment: Alignment.topCenter,
child: isLandscape
? _buildLandscapeLayout(p)
: _buildPortraitLayout(p),
child: isLandscape ? _buildLandscapeLayout(p) : _buildPortraitLayout(p),
),
);
}
@@ -82,11 +86,7 @@ class _EditorTopNavState extends State<EditorTopNav> {
Widget _buildLandscapeLayout(EditorPalette p) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildLeftPill(p),
_buildCenterPill(p),
_buildRightPill(p),
],
children: [_buildLeftPill(p), _buildCenterPill(p), _buildRightPill(p)],
);
}
@@ -97,16 +97,10 @@ class _EditorTopNavState extends State<EditorTopNav> {
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildLeftPill(p),
_buildCenterPill(p),
],
children: [_buildLeftPill(p), _buildCenterPill(p)],
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: _buildRightPill(p),
),
Align(alignment: Alignment.centerRight, child: _buildRightPill(p)),
],
);
}
@@ -165,6 +159,30 @@ class _EditorTopNavState extends State<EditorTopNav> {
return _NavPill(
palette: p,
children: [
if (widget.onToolDrawer != null)
_NavBtn(
icon: EditorIcon.cupertino(
'sidebar_left',
size: 16,
color: p.textPrimary,
),
semanticsLabel: '工具抽屉',
onTap: widget.onToolDrawer!,
palette: p,
),
if (widget.onToolDrawer != null) _PillDivider(palette: p),
if (widget.onCanvasStyle != null)
_NavBtn(
icon: EditorIcon.cupertino(
'rectangle',
size: 16,
color: p.textPrimary,
),
semanticsLabel: '画布样式',
onTap: widget.onCanvasStyle!,
palette: p,
),
if (widget.onCanvasStyle != null) _PillDivider(palette: p),
_NavBtn(
icon: EditorIcon.svg('moon', size: 16, color: p.textPrimary),
semanticsLabel: '切换主题',

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,14 @@
// ============================================================
// 闲言APP — 编辑器设置面板
// 创建时间: 2026-05-04
// 更新时间: 2026-05-04
// 作用: 编辑器设置面板 — 描边风格切换
// 上次更新: 初始创建
// 更新时间: 2026-05-05
// 作用: 编辑器设置面板 — 描边风格切换
// 上次更新: 画布圆角/边框/阴影/叠层移至CanvasStyleSheet独立文件
// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/editor/models/editor_models.dart';
import 'package:xianyan/editor/services/core/color_tokens.dart';
import 'package:xianyan/editor/services/core/editor_theme_notifier.dart';

View File

@@ -3,7 +3,7 @@
// 创建时间: 2026-05-04
// 更新时间: 2026-05-05
// 作用: 富文本编辑面板 — 工具栏+编辑区,实时预览
// 上次更新: 空安全加固 + 异常保护防卡死 + 本地flutter_quill包引用
// 上次更新: 添加Material祖先部件 + 延迟autoFocus防崩溃 + 空安全加固
// ============================================================
import 'dart:convert';
@@ -35,11 +35,17 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
final _focusNode = FocusNode();
final _scrollController = ScrollController();
bool _initError = false;
bool _editorReady = false;
@override
void initState() {
super.initState();
_initController();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() => _editorReady = true);
}
});
}
void _initController() {
@@ -60,8 +66,9 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
_controller = quill.QuillController.basic();
}
_controller!.addListener(_onContentChanged);
} catch (_) {
_controller?.addListener(_onContentChanged);
} catch (e) {
debugPrint('[RichTextEditorPanel] _initController error: $e');
if (mounted) {
setState(() => _initError = true);
}
@@ -69,18 +76,20 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
}
void _onContentChanged() {
if (_controller == null) return;
final ctrl = _controller;
if (ctrl == null) return;
try {
final delta = _controller!.document.toDelta();
final delta = ctrl.document.toDelta();
widget.onChanged?.call(jsonEncode(delta.toJson()));
} catch (_) {}
}
@override
void dispose() {
if (_controller != null) {
_controller!.removeListener(_onContentChanged);
_controller!.dispose();
final ctrl = _controller;
if (ctrl != null) {
ctrl.removeListener(_onContentChanged);
ctrl.dispose();
}
_focusNode.dispose();
_scrollController.dispose();
@@ -95,48 +104,57 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
final ext = AppTheme.ext(context);
return Container(
decoration: BoxDecoration(
color: ext.bgPrimary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
_buildHeader(ext),
_buildToolbar(ext),
const Divider(height: 0.5),
Expanded(child: _buildEditor(ext)),
],
return Material(
color: Colors.transparent,
child: Container(
decoration: BoxDecoration(
color: ext.bgPrimary,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
_buildHeader(ext),
_buildToolbar(ext),
const Divider(height: 0.5),
Expanded(child: _buildEditor(ext)),
],
),
),
);
}
Widget _buildErrorState() {
return Container(
decoration: const BoxDecoration(
color: CupertinoColors.systemBackground,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.exclamationmark_circle,
size: 48,
color: CupertinoColors.systemGrey,
),
const SizedBox(height: 12),
const Text(
'编辑器初始化失败',
style: TextStyle(fontSize: 16, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 16),
CupertinoButton.filled(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
],
return Material(
color: Colors.transparent,
child: Container(
decoration: const BoxDecoration(
color: CupertinoColors.systemBackground,
borderRadius:
BorderRadius.vertical(top: Radius.circular(16)),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.exclamationmark_circle,
size: 48,
color: CupertinoColors.systemGrey,
),
const SizedBox(height: 12),
const Text(
'编辑器初始化失败',
style: TextStyle(
fontSize: 16, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 16),
CupertinoButton.filled(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
],
),
),
),
);
@@ -144,10 +162,12 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
Widget _buildHeader(AppThemeExtension ext) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
@@ -161,7 +181,8 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
),
const Spacer(),
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minSize: 0,
onPressed: () {
_focusNode.unfocus();
@@ -182,13 +203,14 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
}
Widget _buildToolbar(AppThemeExtension ext) {
if (_controller == null) return const SizedBox.shrink();
final ctrl = _controller;
if (ctrl == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(vertical: 4),
color: ext.bgSecondary,
child: quill.QuillSimpleToolbar(
controller: _controller!,
controller: ctrl,
config: quill.QuillSimpleToolbarConfig(
toolbarSize: 36,
showAlignmentButtons: true,
@@ -222,7 +244,8 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
showFontFamily: false,
showFontSize: false,
multiRowsDisplay: true,
sectionDividerColor: ext.textSecondary.withValues(alpha: 0.2),
sectionDividerColor:
ext.textSecondary.withValues(alpha: 0.2),
buttonOptions: const quill.QuillSimpleToolbarButtonOptions(
base: quill.QuillToolbarBaseButtonOptions(iconSize: 18),
),
@@ -232,22 +255,27 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
}
Widget _buildEditor(AppThemeExtension ext) {
if (_controller == null) return const SizedBox.shrink();
final ctrl = _controller;
if (ctrl == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: quill.QuillEditor.basic(
controller: _controller!,
controller: ctrl,
focusNode: _focusNode,
scrollController: _scrollController,
config: quill.QuillEditorConfig(
padding: const EdgeInsets.all(8),
autoFocus: true,
autoFocus: _editorReady,
expands: false,
placeholder: '输入文本内容...',
customStyles: quill.DefaultStyles(
paragraph: quill.DefaultTextBlockStyle(
TextStyle(color: ext.textPrimary, fontSize: 16, height: 1.6),
TextStyle(
color: ext.textPrimary,
fontSize: 16,
height: 1.6),
const quill.HorizontalSpacing(0, 0),
const quill.VerticalSpacing(6, 0),
const quill.VerticalSpacing(0, 0),
@@ -259,11 +287,13 @@ class _RichTextEditorPanelState extends State<RichTextEditorPanel> {
);
}
String get plainText => _controller?.document.toPlainText() ?? '';
String get plainText =>
_controller?.document.toPlainText() ?? '';
String get deltaJson => _controller != null
? jsonEncode(_controller!.document.toDelta().toJson())
: '[]';
String get htmlContent => _controller?.document.toPlainText() ?? '';
String get htmlContent =>
_controller?.document.toPlainText() ?? '';
}

View File

@@ -1,14 +1,15 @@
// ============================================================
// 闲言APP — Feed信息流API服务
// 创建时间: 2026-04-28
// 更新时间: 2026-04-29
// 更新时间: 2026-05-05
// 作用: 封装所有 /api/feed/* 接口调用
// 上次更新: 新增 fetchComments/fetchStats/fetchRelatedRecommend 3个接口
// 上次更新: action方法增加登录检查未登录跳过API请求避免401
// ============================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/storage/secure_storage.dart';
import '../../../core/utils/logger.dart';
import '../models/feed_model.dart';
@@ -114,6 +115,14 @@ class FeedService {
required int feedId,
String? extra,
}) async {
if (action != 'view') {
final loggedIn = await SecureStorage.isLoggedIn;
if (!loggedIn) {
Log.w('Feed互动跳过: 未登录, action=$action');
return false;
}
}
try {
final data = <String, dynamic>{
'action': action,

View File

@@ -3,8 +3,8 @@
/// 创建时间: 2026-05-04
/// 更新时间: 2026-05-05
/// 作用: 壁纸图库公共视图 — 支持drawer(编辑器抽屉)/fullscreen(灵感页)双模式
/// 统一数据源 + 瀑布流 + 已加载优先 + 分类"全部" + URL三级回退
/// 上次更新: 默认全部12源加载(谁先好显示谁) + 异常保护防卡死
/// 统一数据源 + 瀑布流 + 已加载优先 + 分类"全部" + URL三级回退 + 无限下拉加载
/// 上次更新: 全部源模式支持无限下拉(轮询各源分页)+单源分页+去重
/// ============================================================
import 'package:flutter/cupertino.dart';
@@ -50,6 +50,8 @@ class _WallpaperGalleryViewState extends State<WallpaperGalleryView> {
String? _selectedId;
String _searchQuery = '';
Set<String> _cachedIds = {};
final Set<String> _loadedIds = {};
int _allSourcesPageIndex = 1;
final _searchController = TextEditingController();
final _scrollController = ScrollController();
@@ -72,7 +74,9 @@ class _WallpaperGalleryViewState extends State<WallpaperGalleryView> {
if (!_hasMore && !reset) return;
if (reset) {
_currentPage = 1;
_allSourcesPageIndex = 1;
_hasMore = true;
_loadedIds.clear();
}
setState(() => _isLoading = true);
@@ -95,21 +99,49 @@ class _WallpaperGalleryViewState extends State<WallpaperGalleryView> {
Future<void> _loadAllSources({bool reset = true}) async {
final allSources = WallpaperSource.values.toList();
final page = _allSourcesPageIndex;
final items = await WallpaperService.fetchMultiSource(
sources: allSources,
limitPerSource: 4,
category: _category,
).timeout(const Duration(seconds: 20), onTimeout: () => <WallpaperItem>[]);
final futures = allSources.map(
(source) =>
WallpaperService.fetchWallpapers(
source: source,
page: page,
limit: 4,
category: _category,
).catchError(
(_) =>
const WallpaperResult(items: [], currentPage: 1, totalPages: 0),
),
);
final results = await Future.wait(futures, eagerError: false);
if (!mounted) return;
final newItems = <WallpaperItem>[];
for (final result in results) {
for (final item in result.items) {
if (!_loadedIds.contains(item.id)) {
newItems.add(item);
_loadedIds.add(item.id);
}
}
}
newItems.shuffle();
final anyHasNext = results.any((r) => r.hasNext);
final maxTotalPages = results
.map((r) => r.totalPages)
.reduce((a, b) => a > b ? a : b);
setState(() {
if (reset) {
_items = items;
_items = newItems;
} else {
_items.addAll(items);
_items.addAll(newItems);
}
_hasMore = false;
_allSourcesPageIndex++;
_hasMore = anyHasNext || _allSourcesPageIndex <= maxTotalPages;
_isLoading = false;
});
_sortLoadedFirst();
@@ -131,13 +163,21 @@ class _WallpaperGalleryViewState extends State<WallpaperGalleryView> {
);
if (!mounted) return;
final newItems = result.items
.where((item) => !_loadedIds.contains(item.id))
.toList();
for (final item in newItems) {
_loadedIds.add(item.id);
}
setState(() {
if (reset) {
_items = result.items;
_items = newItems;
} else {
_items.addAll(result.items);
_items.addAll(newItems);
}
_hasMore = result.hasNext;
_hasMore = result.hasNext && newItems.isNotEmpty;
_isLoading = false;
_sortLoadedFirst();
});