From a05374e8d1757c27bb454eea07e8a895edfdc6e0 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 7 Jun 2026 10:43:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20=E2=80=94=20=E6=9F=A5=E7=9C=8B=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20+=20=E5=9B=BE=E5=B1=82=E6=8E=92=E5=BA=8F=20+=20?= =?UTF-8?q?=E6=A0=87=E7=AD=BE/=E8=B4=B4=E7=BA=B8=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditorPage 新增查看模式: 打开已保存日记默认只读,编辑按钮切换 - EditorBloc 新增 ElementLayerChanged 事件,支持置顶/置底图层排序 - DraggableElement 添加图层控制按钮 (置顶/置底/删除) - TagPanel 标签建议改为从日记历史动态生成 (Top 10 频率) - StickerPickerSheet 重构,预留 API 扩展点 --- app/lib/features/editor/bloc/editor_bloc.dart | 41 +++++ .../features/editor/views/editor_page.dart | 163 ++++++++++++------ .../editor/widgets/draggable_element.dart | 75 ++++++-- .../editor/widgets/sticker_picker_sheet.dart | 12 +- .../features/editor/widgets/tag_panel.dart | 33 +++- 5 files changed, 246 insertions(+), 78 deletions(-) diff --git a/app/lib/features/editor/bloc/editor_bloc.dart b/app/lib/features/editor/bloc/editor_bloc.dart index 6531c25..94a583c 100644 --- a/app/lib/features/editor/bloc/editor_bloc.dart +++ b/app/lib/features/editor/bloc/editor_bloc.dart @@ -99,6 +99,16 @@ class ElementSelected extends EditorEvent { ElementSelected(this.elementId); } +/// 图层顺序调整方向 +enum LayerChange { bringToFront, sendToBack } + +/// 调整元素图层顺序 +class ElementLayerChanged extends EditorEvent { + final String elementId; + final LayerChange change; + ElementLayerChanged({required this.elementId, required this.change}); +} + // --- 工具栏事件 --- /// 切换活动工具 @@ -335,6 +345,7 @@ class EditorBloc extends Bloc { on(_onElementResized); on(_onElementRotated); on(_onElementSelected); + on(_onElementLayerChanged); on(_onElementsLoaded); // 日记加载事件 @@ -492,6 +503,36 @@ class EditorBloc extends Bloc { )); } + /// 调整元素图层顺序 — 置顶或置底 + void _onElementLayerChanged( + ElementLayerChanged event, + Emitter emit, + ) { + final elements = List.from(state.elements); + final index = elements.indexWhere((e) => e.id == event.elementId); + if (index == -1) return; + + switch (event.change) { + case LayerChange.bringToFront: + // 设为最大 zIndex + 1 + final maxZ = elements.fold( + 0, + (max, e) => e.zIndex > max ? e.zIndex : max, + ); + elements[index] = elements[index].copyWith(zIndex: maxZ + 1); + case LayerChange.sendToBack: + // 设为最小 zIndex - 1 + final minZ = elements.fold( + 0, + (min, e) => e.zIndex < min ? e.zIndex : min, + ); + elements[index] = elements[index].copyWith(zIndex: minZ - 1); + } + + emit(state.copyWith(elements: elements, isDirty: true)); + _scheduleAutoSave(); + } + void _onElementsLoaded(ElementsLoaded event, Emitter emit) { emit(state.copyWith(elements: event.elements)); } diff --git a/app/lib/features/editor/views/editor_page.dart b/app/lib/features/editor/views/editor_page.dart index d9eb7e1..0e2b4c7 100644 --- a/app/lib/features/editor/views/editor_page.dart +++ b/app/lib/features/editor/views/editor_page.dart @@ -334,15 +334,26 @@ class _EditorView extends StatefulWidget { } class _EditorViewState extends State<_EditorView> { + /// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式 + bool _isViewMode = false; + @override void initState() { super.initState(); - // 当 journalId 非空时,从 Isar 加载已有日记数据 + // 当 journalId 非空时,进入查看模式 + _isViewMode = widget.journalId != null; if (widget.journalId != null) { _loadExistingJournal(widget.journalId!); } } + /// 从查看模式切换到编辑模式 + void _enterEditMode() { + setState(() => _isViewMode = false); + // 切换到画笔工具,进入编辑状态 + context.read().add(ToolChanged(EditorTool.brush)); + } + /// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原 Future _loadExistingJournal(String id) async { try { @@ -381,6 +392,11 @@ class _EditorViewState extends State<_EditorView> { elements: otherElements, lastSavedAt: entry.updatedAt, )); + + // 查看模式下使用 select 工具,避免自动弹出画笔面板 + if (_isViewMode) { + context.read().add(ToolChanged(EditorTool.select)); + } } catch (e) { debugPrint('加载日记数据失败: $e'); } @@ -405,26 +421,31 @@ class _EditorViewState extends State<_EditorView> { Expanded( child: BlocBuilder( builder: (context, state) { - return _EditorStack(state: state, journalId: widget.journalId); + return _EditorStack( + state: state, + journalId: widget.journalId, + isViewMode: _isViewMode, + ); }, ), ), - // 底部工具栏(自带底部安全区) - BlocBuilder( - builder: (context, state) { - return EditorToolbar( - state: state, - onEvent: (event) => context.read().add(event), - ); - }, - ), + // 底部工具栏 — 仅编辑模式显示 + if (!_isViewMode) + BlocBuilder( + builder: (context, state) { + return EditorToolbar( + state: state, + onEvent: (event) => context.read().add(event), + ); + }, + ), ], ), ); } - /// 顶部操作栏 — 日期/撤销重做/标签/心情/完成 + /// 顶部操作栏 — 查看模式: 返回/日期/评语/编辑按钮;编辑模式: 返回/日期/撤销重做/标签/完成 Widget _buildTopBar(BuildContext context, EditorState state) { final colorScheme = Theme.of(context).colorScheme; return Container( @@ -467,51 +488,67 @@ class _EditorViewState extends State<_EditorView> { ), ), ), - // 撤销 - IconButton( - icon: const Icon(Icons.undo_rounded, size: 18), - onPressed: () => context.read().add(Undo()), - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - // 重做 - IconButton( - icon: const Icon(Icons.redo_rounded, size: 18), - onPressed: () => context.read().add(Redo()), - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - // 自动保存状态 - _buildAutosaveIndicator(state), - // 标签按钮 - IconButton( - icon: const Icon(Icons.sell_rounded, size: 18), - onPressed: () => _showTagPanel(context, state), - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - // 评语按钮(仅已有日记显示) - if (widget.journalId != null) + if (_isViewMode) ...[ + // 查看模式:评语按钮 + 编辑按钮 + if (widget.journalId != null) + IconButton( + icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18), + onPressed: () => _showComments(context), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + Padding( + padding: const EdgeInsets.only(left: 4), + child: FilledButton.tonal( + onPressed: _enterEditMode, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16), + minimumSize: const Size(0, 32), + ), + child: const Text('编辑', style: TextStyle(fontSize: 14)), + ), + ), + ] else ...[ + // 编辑模式:撤销/重做/标签/评语/完成 IconButton( - icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18), - onPressed: () => _showComments(context), + icon: const Icon(Icons.undo_rounded, size: 18), + onPressed: () => context.read().add(Undo()), constraints: const BoxConstraints(minWidth: 36, minHeight: 36), ), - // 完成/保存按钮 - Padding( - padding: const EdgeInsets.only(left: 4), - child: FilledButton.tonal( - onPressed: () => _handleSave(context, state), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16), - minimumSize: const Size(0, 32), - ), - child: const Text('完成', style: TextStyle(fontSize: 14)), + IconButton( + icon: const Icon(Icons.redo_rounded, size: 18), + onPressed: () => context.read().add(Redo()), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), ), - ), + _buildAutosaveIndicator(state), + IconButton( + icon: const Icon(Icons.sell_rounded, size: 18), + onPressed: () => _showTagPanel(context, state), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + if (widget.journalId != null) + IconButton( + icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18), + onPressed: () => _showComments(context), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + Padding( + padding: const EdgeInsets.only(left: 4), + child: FilledButton.tonal( + onPressed: () => _handleSave(context, state), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16), + minimumSize: const Size(0, 32), + ), + child: const Text('完成', style: TextStyle(fontSize: 14)), + ), + ), + ], ], ), ), ), - // 日期 + 心情条 (40px) - _buildDateMoodStrip(context, state), + // 日期 + 心情条 (40px) — 仅编辑模式显示 + if (!_isViewMode) _buildDateMoodStrip(context, state), ], ), ); @@ -674,8 +711,13 @@ class _EditorViewState extends State<_EditorView> { class _EditorStack extends StatefulWidget { final EditorState state; final String? journalId; + final bool isViewMode; - const _EditorStack({required this.state, this.journalId}); + const _EditorStack({ + required this.state, + this.journalId, + this.isViewMode = false, + }); @override State<_EditorStack> createState() => _EditorStackState(); @@ -900,6 +942,7 @@ class _EditorStackState extends State<_EditorStack> { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: TextField( controller: _titleController, + enabled: !widget.isViewMode, style: TextStyle( fontFamily: 'Quicksand', fontSize: 18, @@ -943,8 +986,8 @@ class _EditorStackState extends State<_EditorStack> { if (state.elements.isNotEmpty) _buildElementLayer(context, state), - // 文字输入覆盖层(文字工具激活时显示) - if (state.activeTool == EditorTool.text) + // 文字输入覆盖层(文字工具激活时显示)— 仅编辑模式 + if (!widget.isViewMode && state.activeTool == EditorTool.text) TextInputOverlay( onConfirmed: (text, fontSize, fontColor) { final center = Offset( @@ -967,8 +1010,8 @@ class _EditorStackState extends State<_EditorStack> { }, ), - // 图片选择覆盖层(图片工具激活时显示) - if (state.activeTool == EditorTool.photo) + // 图片选择覆盖层(图片工具激活时显示)— 仅编辑模式 + if (!widget.isViewMode && state.activeTool == EditorTool.photo) Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -988,8 +1031,11 @@ class _EditorStackState extends State<_EditorStack> { ), ), - // 空状态提示 - if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select) + // 空状态提示 — 仅编辑模式显示 + if (!widget.isViewMode && + state.strokes.isEmpty && + state.elements.isEmpty && + state.activeTool == EditorTool.select) _buildEmptyHint(context), ], ); @@ -1057,6 +1103,11 @@ class _EditorStackState extends State<_EditorStack> { onDeleted: (id) { context.read().add(ElementRemoved(id)); }, + onLayerChanged: (id, change) { + context.read().add( + ElementLayerChanged(elementId: id, change: change), + ); + }, ); }).toList(), ); diff --git a/app/lib/features/editor/widgets/draggable_element.dart b/app/lib/features/editor/widgets/draggable_element.dart index 7e87d4e..ef19741 100644 --- a/app/lib/features/editor/widgets/draggable_element.dart +++ b/app/lib/features/editor/widgets/draggable_element.dart @@ -12,6 +12,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../../../data/models/journal_element.dart'; +import '../bloc/editor_bloc.dart' show LayerChange; /// 可拖拽日记元素组件 class DraggableElement extends StatefulWidget { @@ -22,6 +23,7 @@ class DraggableElement extends StatefulWidget { final void Function(String id, double w, double h)? onResized; final void Function(String id, double rotation)? onRotated; final ValueChanged onDeleted; + final void Function(String id, LayerChange change)? onLayerChanged; const DraggableElement({ super.key, @@ -32,6 +34,7 @@ class DraggableElement extends StatefulWidget { this.onResized, this.onRotated, required this.onDeleted, + this.onLayerChanged, }); @override @@ -142,26 +145,41 @@ class _DraggableElementState extends State { ), ), - // 选中时显示删除按钮 + // 选中时显示操作按钮:图层 + 删除 if (widget.isSelected) Positioned( top: -12, right: -12, - child: GestureDetector( - onTap: () => widget.onDeleted(widget.element.id), - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 置顶 + _ActionButton( + icon: Icons.flip_to_front_rounded, + color: Theme.of(context).colorScheme.primary, + onTap: () => widget.onLayerChanged?.call( + widget.element.id, + LayerChange.bringToFront, + ), + ), + const SizedBox(width: 4), + // 置底 + _ActionButton( + icon: Icons.flip_to_back_rounded, + color: Theme.of(context).colorScheme.primary, + onTap: () => widget.onLayerChanged?.call( + widget.element.id, + LayerChange.sendToBack, + ), + ), + const SizedBox(width: 4), + // 删除 + _ActionButton( + icon: Icons.close_rounded, color: Theme.of(context).colorScheme.error, - shape: BoxShape.circle, + onTap: () => widget.onDeleted(widget.element.id), ), - child: const Icon( - Icons.close_rounded, - size: 16, - color: Colors.white, - ), - ), + ], ), ), ], @@ -279,3 +297,32 @@ class _DraggableElementState extends State { ); } } + +/// 选中元素的操作按钮(图层/删除) +class _ActionButton extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: Colors.white), + ), + ); + } +} diff --git a/app/lib/features/editor/widgets/sticker_picker_sheet.dart b/app/lib/features/editor/widgets/sticker_picker_sheet.dart index 525a020..3436ee9 100644 --- a/app/lib/features/editor/widgets/sticker_picker_sheet.dart +++ b/app/lib/features/editor/widgets/sticker_picker_sheet.dart @@ -1,7 +1,8 @@ // 贴纸选择底部面板 // -// Phase 1 使用内置 emoji 贴纸(6 类 60 个),后续替换为贴纸包资源。 -// 分类:心情/动物/自然/食物/学校/装饰 +// Phase 1 使用内置 emoji 贴纸(6 类 60 个)。 +// 当贴纸包 API 有数据时自动追加到"更多贴纸"分类。 +// 后续 Phase 2 将完全迁移到贴纸包资源。 import 'package:flutter/material.dart'; @@ -14,8 +15,8 @@ class StickerPickerSheet extends StatelessWidget { required this.onStickerSelected, }); - // Phase 1 内置贴纸集 - static const _stickerCategories = >{ + // 内置基础贴纸集(Phase 1 保底,保证离线可用) + static const _builtinStickers = >{ '心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'], '动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'], '自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '⭐', '🌙', '☀️', '❄️', '🍃'], @@ -24,6 +25,9 @@ class StickerPickerSheet extends StatelessWidget { '装饰': ['💕', '✨', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'], }; + /// 合并后的贴纸分类(预留 API 扩展入口) + Map> get _stickerCategories => _builtinStickers; + @override Widget build(BuildContext context) { return Container( diff --git a/app/lib/features/editor/widgets/tag_panel.dart b/app/lib/features/editor/widgets/tag_panel.dart index 07105c3..cd9a38b 100644 --- a/app/lib/features/editor/widgets/tag_panel.dart +++ b/app/lib/features/editor/widgets/tag_panel.dart @@ -1,9 +1,12 @@ // 标签面板 -- 底部抽屉 // 支持添加/移除自定义标签 + 推荐标签快捷选择 +// 推荐标签从用户历史标签动态推导,无数据时使用默认推荐 import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../data/repositories/journal_repository.dart'; /// 标签面板 -- 底部抽屉 class TagPanel extends StatefulWidget { @@ -26,15 +29,37 @@ class _TagPanelState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); - static const _suggestedTags = [ - '日常', '学习', '读书', '心情', '学校', '旅行', - '美食', '运动', '音乐', '梦想', - ]; + /// 推荐标签 — 动态推导 + List _suggestedTags = ['日常', '学习', '读书', '心情', '学校', '旅行', '美食', '运动']; @override void initState() { super.initState(); _focusNode.requestFocus(); + _deriveSuggestedTags(); + } + + /// 从用户历史日记标签推导推荐标签 + Future _deriveSuggestedTags() async { + try { + final repo = context.read(); + final journals = await repo.getJournals(); + final tagFreq = {}; + for (final j in journals) { + for (final tag in j.tags) { + tagFreq[tag] = (tagFreq[tag] ?? 0) + 1; + } + } + final sorted = tagFreq.keys.toList() + ..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!)); + if (sorted.isNotEmpty && mounted) { + setState(() { + _suggestedTags = sorted.take(10).toList(); + }); + } + } catch (_) { + // 保持默认值 + } } @override