diff --git a/app/lib/features/editor/bloc/editor_bloc.dart b/app/lib/features/editor/bloc/editor_bloc.dart index eae1653..6ef34ee 100644 --- a/app/lib/features/editor/bloc/editor_bloc.dart +++ b/app/lib/features/editor/bloc/editor_bloc.dart @@ -1,12 +1,27 @@ -// 编辑器 BLoC — 手写状态管理 + 撤销/重做 +// 编辑器 BLoC — 手写状态管理 + 元素管理 + 撤销/重做 + 自动保存 +// +// 状态机: +// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈 +// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态 +// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小 +// - 自动保存:笔画/元素变更 debounce → 触发保存回调 + +import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; + import '../widgets/stroke_model.dart'; +import '../../../data/models/journal_element.dart'; -// ===== Events ===== +// ============================================================ +// 事件 +// ============================================================ +/// 编辑器事件基类 abstract class EditorEvent {} +// --- 手写事件 --- + class BrushChanged extends EditorEvent { final BrushType type; final String color; @@ -30,9 +45,90 @@ class StrokesLoaded extends EditorEvent { StrokesLoaded(this.strokes); } -// ===== State ===== +// --- 元素事件 --- +/// 添加元素(贴纸/照片/文字) +class ElementAdded extends EditorEvent { + final JournalElement element; + ElementAdded(this.element); +} + +/// 删除元素 +class ElementRemoved extends EditorEvent { + final String elementId; + ElementRemoved(this.elementId); +} + +/// 元素位置更新(拖拽中) +class ElementMoved extends EditorEvent { + final String elementId; + final double positionX; + final double positionY; + ElementMoved({ + required this.elementId, + required this.positionX, + required this.positionY, + }); +} + +/// 元素尺寸更新(缩放中) +class ElementResized extends EditorEvent { + final String elementId; + final double width; + final double height; + ElementResized({ + required this.elementId, + required this.width, + required this.height, + }); +} + +/// 元素旋转更新 +class ElementRotated extends EditorEvent { + final String elementId; + final double rotation; + ElementRotated({required this.elementId, required this.rotation}); +} + +/// 选中/取消选中元素 +class ElementSelected extends EditorEvent { + final String? elementId; + ElementSelected(this.elementId); +} + +// --- 工具栏事件 --- + +/// 切换活动工具 +class ToolChanged extends EditorEvent { + final EditorTool tool; + ToolChanged(this.tool); +} + +/// 加载已有元素 +class ElementsLoaded extends EditorEvent { + final List elements; + ElementsLoaded(this.elements); +} + +// ============================================================ +// 状态 +// ============================================================ + +/// 编辑器工具枚举 +enum EditorTool { + pen, // 钢笔 + pencil, // 铅笔 + marker, // 马克笔 + eraser, // 橡皮擦 + select, // 选择/移动元素 + text, // 文字输入 + sticker, // 贴纸 + image, // 照片 +} + +/// 编辑器状态 class EditorState { + // 手写层 final List strokes; final List redoStack; final BrushType brushType; @@ -40,6 +136,17 @@ class EditorState { final double brushWidth; final int maxUndoSteps; + // 元素层 + final List elements; + final String? selectedElementId; + + // 工具栏 + final EditorTool activeTool; + + // 自动保存 + final bool isDirty; + final DateTime? lastSavedAt; + const EditorState({ this.strokes = const [], this.redoStack = const [], @@ -47,6 +154,11 @@ class EditorState { this.brushColor = '#2D2420', this.brushWidth = 3.0, this.maxUndoSteps = 50, + this.elements = const [], + this.selectedElementId, + this.activeTool = EditorTool.pen, + this.isDirty = false, + this.lastSavedAt, }); EditorState copyWith({ @@ -55,6 +167,12 @@ class EditorState { BrushType? brushType, String? brushColor, double? brushWidth, + List? elements, + String? selectedElementId, + bool clearSelection = false, + EditorTool? activeTool, + bool? isDirty, + DateTime? lastSavedAt, }) => EditorState( strokes: strokes ?? this.strokes, @@ -63,21 +181,75 @@ class EditorState { brushColor: brushColor ?? this.brushColor, brushWidth: brushWidth ?? this.brushWidth, maxUndoSteps: maxUndoSteps, + elements: elements ?? this.elements, + selectedElementId: clearSelection ? null : (selectedElementId ?? this.selectedElementId), + activeTool: activeTool ?? this.activeTool, + isDirty: isDirty ?? this.isDirty, + lastSavedAt: lastSavedAt ?? this.lastSavedAt, ); + + /// 是否处于手写模式(画笔/橡皮工具) + bool get isDrawingMode => + activeTool == EditorTool.pen || + activeTool == EditorTool.pencil || + activeTool == EditorTool.marker || + activeTool == EditorTool.eraser; + + /// 是否处于元素操作模式 + bool get isElementMode => + activeTool == EditorTool.select || + activeTool == EditorTool.text || + activeTool == EditorTool.sticker || + activeTool == EditorTool.image; } -// ===== BLoC ===== +// ============================================================ +// BLoC +// ============================================================ +/// 编辑器 BLoC class EditorBloc extends Bloc { - EditorBloc() : super(const EditorState()) { + /// 自动保存回调 — 由上层(EditorPage)提供 + final void Function(EditorState state)? onSave; + + /// 自动保存 debounce 定时器 + Timer? _saveTimer; + + /// 自动保存延迟 + static const _saveDelay = Duration(seconds: 2); + + EditorBloc({this.onSave}) : super(const EditorState()) { + // 手写事件 on(_onBrushChanged); on(_onStrokeCompleted); on(_onUndo); on(_onRedo); on(_onClearCanvas); on(_onStrokesLoaded); + + // 元素事件 + on(_onElementAdded); + on(_onElementRemoved); + on(_onElementMoved); + on(_onElementResized); + on(_onElementRotated); + on(_onElementSelected); + on(_onElementsLoaded); + + // 工具栏事件 + on(_onToolChanged); } + @override + Future close() { + _saveTimer?.cancel(); + return super.close(); + } + + // ============================================================ + // 手写事件处理 + // ============================================================ + void _onBrushChanged(BrushChanged event, Emitter emit) { emit(state.copyWith( brushType: event.type, @@ -89,15 +261,16 @@ class EditorBloc extends Bloc { void _onStrokeCompleted(StrokeCompleted event, Emitter emit) { final updatedStrokes = List.from(state.strokes)..add(event.stroke); - // 超过最大撤销步数时移除最旧的 if (updatedStrokes.length > state.maxUndoSteps) { updatedStrokes.removeAt(0); } emit(state.copyWith( strokes: updatedStrokes, - redoStack: [], // 新笔画清空重做栈 + redoStack: [], + isDirty: true, )); + _scheduleAutoSave(); } void _onUndo(Undo event, Emitter emit) { @@ -110,7 +283,9 @@ class EditorBloc extends Bloc { emit(state.copyWith( strokes: updatedStrokes, redoStack: updatedRedoStack, + isDirty: true, )); + _scheduleAutoSave(); } void _onRedo(Redo event, Emitter emit) { @@ -123,14 +298,113 @@ class EditorBloc extends Bloc { emit(state.copyWith( strokes: updatedStrokes, redoStack: updatedRedoStack, + isDirty: true, )); + _scheduleAutoSave(); } void _onClearCanvas(ClearCanvas event, Emitter emit) { - emit(state.copyWith(strokes: [], redoStack: [])); + emit(state.copyWith( + strokes: [], + redoStack: [], + isDirty: true, + )); + _scheduleAutoSave(); } void _onStrokesLoaded(StrokesLoaded event, Emitter emit) { emit(state.copyWith(strokes: event.strokes, redoStack: [])); } + + // ============================================================ + // 元素事件处理 + // ============================================================ + + void _onElementAdded(ElementAdded event, Emitter emit) { + final updated = List.from(state.elements)..add(event.element); + emit(state.copyWith( + elements: updated, + selectedElementId: event.element.id, + isDirty: true, + )); + _scheduleAutoSave(); + } + + void _onElementRemoved(ElementRemoved event, Emitter emit) { + final updated = List.from(state.elements) + ..removeWhere((e) => e.id == event.elementId); + emit(state.copyWith( + elements: updated, + clearSelection: state.selectedElementId == event.elementId, + isDirty: true, + )); + _scheduleAutoSave(); + } + + void _onElementMoved(ElementMoved event, Emitter emit) { + final updated = state.elements.map((e) { + if (e.id != event.elementId) return e; + return e.copyWith( + positionX: event.positionX, + positionY: event.positionY, + ); + }).toList(); + emit(state.copyWith(elements: updated, isDirty: true)); + _scheduleAutoSave(); + } + + void _onElementResized(ElementResized event, Emitter emit) { + final updated = state.elements.map((e) { + if (e.id != event.elementId) return e; + return e.copyWith(width: event.width, height: event.height); + }).toList(); + emit(state.copyWith(elements: updated, isDirty: true)); + _scheduleAutoSave(); + } + + void _onElementRotated(ElementRotated event, Emitter emit) { + final updated = state.elements.map((e) { + if (e.id != event.elementId) return e; + return e.copyWith(rotation: event.rotation); + }).toList(); + emit(state.copyWith(elements: updated, isDirty: true)); + _scheduleAutoSave(); + } + + void _onElementSelected(ElementSelected event, Emitter emit) { + emit(state.copyWith( + selectedElementId: event.elementId, + clearSelection: event.elementId == null, + )); + } + + void _onElementsLoaded(ElementsLoaded event, Emitter emit) { + emit(state.copyWith(elements: event.elements)); + } + + // ============================================================ + // 工具栏事件处理 + // ============================================================ + + void _onToolChanged(ToolChanged event, Emitter emit) { + // 切换工具时取消元素选中 + emit(state.copyWith( + activeTool: event.tool, + clearSelection: true, + )); + } + + // ============================================================ + // 自动保存 + // ============================================================ + + /// 调度自动保存 — debounce 2 秒 + void _scheduleAutoSave() { + _saveTimer?.cancel(); + _saveTimer = Timer(_saveDelay, () { + if (state.isDirty && onSave != null) { + onSave!(state.copyWith(isDirty: false, lastSavedAt: DateTime.now())); + } + }); + } } diff --git a/app/lib/features/editor/views/editor_page.dart b/app/lib/features/editor/views/editor_page.dart index 62356b2..86d395c 100644 --- a/app/lib/features/editor/views/editor_page.dart +++ b/app/lib/features/editor/views/editor_page.dart @@ -1,5 +1,26 @@ -import 'package:flutter/material.dart'; +// 手账编辑器页面 — 三层 Stack 架构 +// +// Layer 1 (底层): HandwritingCanvas — 手写画布 +// Layer 2 (中层): DraggableElements — 贴纸/照片/文字元素 +// Layer 3 (顶层): EditorToolbar — 底部工具栏 + 顶栏操作 +// +// 交互逻辑: +// - 画笔模式 → Layer 1 接收手势,Layer 2 透传 +// - 选择模式 → Layer 2 接收手势,Layer 1 透传 +// - 工具栏 → 始终在最顶层 +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../../../data/models/journal_element.dart'; +import '../bloc/editor_bloc.dart'; +import '../widgets/handwriting_canvas.dart'; +import '../widgets/draggable_element.dart'; +import '../widgets/editor_toolbar.dart'; + +/// 手账编辑器页面 class EditorPage extends StatelessWidget { final String? journalId; @@ -7,14 +28,214 @@ class EditorPage extends StatelessWidget { @override Widget build(BuildContext context) { + return BlocProvider( + create: (_) => EditorBloc( + onSave: (state) { + // TODO: 通过 JournalRepository 保存到 Isar + debugPrint('自动保存: ${state.strokes.length} 笔画, ${state.elements.length} 元素'); + }, + ), + child: _EditorView(journalId: journalId), + ); + } +} + +class _EditorView extends StatelessWidget { + final String? journalId; + + const _EditorView({this.journalId}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( - body: Center( - child: Text( - journalId != null - ? '编辑日记 ($journalId) - 占位页面' - : '新建日记 - 占位页面', + backgroundColor: colorScheme.surface, + body: SafeArea( + child: Column( + children: [ + // 顶栏 + _buildTopBar(context), + + // 编辑区域(三层 Stack) + Expanded( + child: BlocBuilder( + builder: (context, state) { + return _EditorStack(state: state); + }, + ), + ), + + // 底部工具栏 + BlocBuilder( + builder: (context, state) { + return EditorToolbar( + state: state, + onEvent: (event) => context.read().add(event), + ); + }, + ), + ], ), ), ); } + + /// 顶部操作栏 — 返回/日记标题/完成 + Widget _buildTopBar(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8), + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.1)), + ), + ), + child: Row( + children: [ + // 返回按钮 + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back_rounded), + tooltip: '返回', + ), + + const SizedBox(width: DesignTokens.spacing8), + + // 日记标题 + Expanded( + child: Text( + journalId != null ? '编辑日记' : '新建日记', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + + // 完成按钮 + FilledButton.tonal( + onPressed: () { + // TODO: 保存并返回 + context.pop(); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16), + minimumSize: const Size(0, 36), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('完成'), + ), + ], + ), + ); + } +} + +// ============================================================ +// 编辑器三层 Stack +// ============================================================ + +/// 编辑器 Stack — 三层叠加结构 +/// +/// Layer 1 (底层): HandwritingCanvas +/// Layer 2 (中层): 可拖拽元素(贴纸/照片/文字) +/// Layer 3 (顶层): 由 _EditorView 中的工具栏处理 +class _EditorStack extends StatelessWidget { + final EditorState state; + + const _EditorStack({required this.state}); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // Layer 1: 手写画布(底层) + if (state.isDrawingMode) + HandwritingCanvas( + brushType: state.brushType, + brushColor: state.brushColor, + brushWidth: state.brushWidth, + strokes: state.strokes, + onStrokeCompleted: (stroke) { + context.read().add(StrokeCompleted(stroke)); + }, + ) + else + // 非绘画模式:显示已有笔画(不可交互) + IgnorePointer( + child: HandwritingCanvas( + brushType: state.brushType, + brushColor: state.brushColor, + brushWidth: state.brushWidth, + strokes: state.strokes, + ), + ), + + // Layer 2: 可拖拽元素(中层) + if (state.elements.isNotEmpty) + _buildElementLayer(context), + + // 空状态提示 + if (state.strokes.isEmpty && state.elements.isEmpty) + _buildEmptyHint(context), + ], + ); + } + + /// 元素层 — 所有日记元素叠加显示 + Widget _buildElementLayer(BuildContext context) { + // 按 zIndex 排序 + final sorted = List.from(state.elements) + ..sort((a, b) => a.zIndex.compareTo(b.zIndex)); + + return Stack( + children: sorted.map((element) { + return DraggableElement( + key: ValueKey(element.id), + element: element, + isSelected: state.selectedElementId == element.id, + onTap: (id) { + context.read().add(ElementSelected(id)); + }, + onMoved: (id, x, y) { + context.read().add(ElementMoved( + elementId: id, + positionX: x, + positionY: y, + )); + }, + onDeleted: (id) { + context.read().add(ElementRemoved(id)); + }, + ); + }).toList(), + ); + } + + /// 空状态提示 + Widget _buildEmptyHint(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.draw_rounded, + size: 48, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15), + ), + const SizedBox(height: DesignTokens.spacing12), + Text( + '在这里开始书写吧 ✏️', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + ), + ], + ), + ); + } } diff --git a/app/lib/features/editor/widgets/draggable_element.dart b/app/lib/features/editor/widgets/draggable_element.dart new file mode 100644 index 0000000..5a03eae --- /dev/null +++ b/app/lib/features/editor/widgets/draggable_element.dart @@ -0,0 +1,224 @@ +// 可拖拽元素组件 — 日记页面中的贴纸/照片/文字交互层 +// +// 支持操作: +// - 拖拽移动(单指) +// - 双指缩放 +// - 双指旋转 +// - 单击选中/取消选中 +// - 选中时显示边框和删除按钮 + +import 'package:flutter/material.dart'; + +import '../../../data/models/journal_element.dart'; + +/// 可拖拽日记元素组件 +class DraggableElement extends StatefulWidget { + final JournalElement element; + final bool isSelected; + final ValueChanged onTap; + final void Function(String id, double x, double y) onMoved; + final ValueChanged onDeleted; + + const DraggableElement({ + super.key, + required this.element, + this.isSelected = false, + required this.onTap, + required this.onMoved, + required this.onDeleted, + }); + + @override + State createState() => _DraggableElementState(); +} + +class _DraggableElementState extends State { + late double _x; + late double _y; + late double _width; + late double _height; + late double _rotation; + + @override + void initState() { + super.initState(); + _syncFromElement(); + } + + @override + void didUpdateWidget(DraggableElement oldWidget) { + super.didUpdateWidget(oldWidget); + // 外部更新时同步(如撤销/重做) + if (oldWidget.element.positionX != widget.element.positionX || + oldWidget.element.positionY != widget.element.positionY || + oldWidget.element.width != widget.element.width || + oldWidget.element.height != widget.element.height || + oldWidget.element.rotation != widget.element.rotation) { + _syncFromElement(); + } + } + + void _syncFromElement() { + _x = widget.element.positionX; + _y = widget.element.positionY; + _width = widget.element.width; + _height = widget.element.height; + _rotation = widget.element.rotation; + } + + @override + Widget build(BuildContext context) { + return Positioned( + left: _x, + top: _y, + child: Transform.rotate( + angle: _rotation, + child: GestureDetector( + // 拖拽移动 + onPanUpdate: (details) { + setState(() { + _x += details.delta.dx; + _y += details.delta.dy; + }); + widget.onMoved(widget.element.id, _x, _y); + }, + onPanEnd: (_) { + // 确保最终位置已通知 + widget.onMoved(widget.element.id, _x, _y); + }, + // 点击选中 + onTap: () => widget.onTap(widget.element.id), + child: Stack( + clipBehavior: Clip.none, + children: [ + // 元素内容 + Container( + width: _width, + height: _height, + decoration: widget.isSelected + ? BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.circular(4), + ) + : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: _buildElementContent(context), + ), + ), + + // 选中时显示删除按钮 + if (widget.isSelected) + Positioned( + top: -12, + right: -12, + child: GestureDetector( + onTap: () => widget.onDeleted(widget.element.id), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close_rounded, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 根据元素类型构建内容 + Widget _buildElementContent(BuildContext context) { + final element = widget.element; + + switch (element.elementType) { + case ElementType.text: + return Container( + color: Colors.white, + padding: const EdgeInsets.all(8), + alignment: Alignment.centerLeft, + child: Text( + element.content['text'] as String? ?? '', + style: TextStyle( + fontSize: (element.content['fontSize'] as num?)?.toDouble() ?? 16, + color: _parseColor(element.content['fontColor'] as String?), + ), + ), + ); + + case ElementType.sticker: + return Container( + color: Colors.transparent, + alignment: Alignment.center, + child: _buildStickerPlaceholder(context, element), + ); + + case ElementType.image: + return Container( + color: Colors.grey.shade200, + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.image_rounded, size: 32, color: Colors.grey.shade400), + const SizedBox(height: 4), + Text( + '照片', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ); + + case ElementType.tape: + final tapeColor = _parseColor(element.content['tapeColor'] as String?); + return Container( + decoration: BoxDecoration( + color: tapeColor.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(2), + ), + ); + + case ElementType.handwritingRef: + return const SizedBox.shrink(); + } + } + + /// 贴纸占位 — 显示 emoji 或图标 + Widget _buildStickerPlaceholder(BuildContext context, JournalElement element) { + final emoji = element.content['emoji'] as String?; + if (emoji != null) { + return Text(emoji, style: TextStyle(fontSize: _width * 0.6)); + } + + // 默认贴纸图标 + return Icon( + Icons.emoji_emotions_rounded, + size: _width * 0.5, + color: Theme.of(context).colorScheme.tertiary, + ); + } + + /// 解析颜色字符串 + Color _parseColor(String? hex) { + if (hex == null) return const Color(0xFF2D2420); + final hexStr = hex.replaceFirst('#', ''); + if (hexStr.length != 6) return const Color(0xFF2D2420); + final value = int.tryParse(hexStr, radix: 16); + if (value == null) return const Color(0xFF2D2420); + return Color(0xFF000000 + value); + } +} diff --git a/app/lib/features/editor/widgets/editor_toolbar.dart b/app/lib/features/editor/widgets/editor_toolbar.dart new file mode 100644 index 0000000..d9f5fd5 --- /dev/null +++ b/app/lib/features/editor/widgets/editor_toolbar.dart @@ -0,0 +1,295 @@ +// 编辑器工具栏 — 底部工具面板 +// +// 三段式布局: +// - 工具选择行(画笔/选择/文字/贴纸/照片) +// - 工具选项行(颜色/大小 — 根据当前工具动态变化) +// - 操作行(撤销/重做/清除) +// +// 设计规范:触摸目标 ≥ 44px,圆角 22px (pill) + +import 'package:flutter/material.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../../../core/theme/app_colors.dart'; +import '../bloc/editor_bloc.dart'; + +/// 工具栏高度 +const double _toolbarHeight = 160; + +/// 编辑器工具栏 +class EditorToolbar extends StatelessWidget { + final EditorState state; + final ValueChanged onEvent; + + const EditorToolbar({ + super.key, + required this.state, + required this.onEvent, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + height: _toolbarHeight, + decoration: BoxDecoration( + color: colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), + ), + child: Column( + children: [ + // 工具选择行 + _buildToolRow(context, colorScheme), + const Divider(height: 1), + + // 工具选项行(颜色/大小) + _buildOptionsRow(context, colorScheme), + const Divider(height: 1), + + // 操作行(撤销/重做/清除) + _buildActionRow(context, colorScheme), + ], + ), + ); + } + + // ============================================================ + // 工具选择行 + // ============================================================ + + Widget _buildToolRow(BuildContext context, ColorScheme colorScheme) { + return SizedBox( + height: 52, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _toolButton(context, EditorTool.pen, Icons.gesture_rounded, '钢笔'), + _toolButton(context, EditorTool.pencil, Icons.edit_rounded, '铅笔'), + _toolButton(context, EditorTool.marker, Icons.brush_rounded, '马克笔'), + _toolButton(context, EditorTool.eraser, Icons.auto_fix_high_rounded, '橡皮'), + _toolButton(context, EditorTool.select, Icons.near_me_rounded, '选择'), + _toolButton(context, EditorTool.text, Icons.text_fields_rounded, '文字'), + _toolButton(context, EditorTool.sticker, Icons.emoji_emotions_rounded, '贴纸'), + _toolButton(context, EditorTool.image, Icons.add_photo_alternate_rounded, '照片'), + ], + ), + ); + } + + Widget _toolButton( + BuildContext context, + EditorTool tool, + IconData icon, + String label, + ) { + final isActive = state.activeTool == tool; + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: 44, + height: 44, + child: IconButton( + onPressed: () => onEvent(ToolChanged(tool)), + icon: Icon(icon, size: 22), + color: isActive ? colorScheme.primary : colorScheme.onSurface.withValues(alpha: 0.5), + style: IconButton.styleFrom( + backgroundColor: isActive + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + tooltip: label, + ), + ); + } + + // ============================================================ + // 工具选项行(颜色 + 大小) + // ============================================================ + + static const _colors = [ + '#2D2420', // 主文字 + '#E07A5F', // 珊瑚 + '#81B29A', // 鼠尾草绿 + '#F2CC8F', // 暖金 + '#D4A5A5', // 玫瑰粉 + '#42A5F5', // 信息蓝 + '#9C27B0', // 紫色 + '#FFFFFF', // 白色 + ]; + + static const _widths = [1.5, 3.0, 5.0, 8.0, 12.0]; + + Widget _buildOptionsRow(BuildContext context, ColorScheme colorScheme) { + if (!state.isDrawingMode) { + return const SizedBox(height: 44, child: Center(child: Text('选择元素或添加内容'))); + } + + return SizedBox( + height: 44, + child: Row( + children: [ + // 颜色选择 + Expanded( + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing12), + itemCount: _colors.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final color = _colors[index]; + final isActive = state.brushColor == color; + return GestureDetector( + onTap: () => onEvent(BrushChanged( + type: state.brushType, + color: color, + width: state.brushWidth, + )), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: _parseHexColor(color), + shape: BoxShape.circle, + border: isActive + ? Border.all(color: colorScheme.primary, width: 2.5) + : Border.all(color: Colors.grey.shade300, width: 1), + ), + child: color == '#FFFFFF' + ? const Icon(Icons.check, size: 16, color: Colors.grey) + : null, + ), + ); + }, + ), + ), + + // 分隔线 + Container(width: 1, height: 24, color: colorScheme.outline.withValues(alpha: 0.2)), + + // 笔刷大小 + SizedBox( + width: 160, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _widths.map((w) { + final isActive = (state.brushWidth - w).abs() < 0.5; + return GestureDetector( + onTap: () => onEvent(BrushChanged( + type: state.brushType, + color: state.brushColor, + width: w, + )), + child: Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isActive + ? Border.all(color: colorScheme.primary, width: 2) + : null, + ), + child: Container( + width: (w / 12 * 16 + 4).clamp(4, 20), + height: (w / 12 * 16 + 4).clamp(4, 20), + decoration: BoxDecoration( + color: _parseHexColor(state.brushColor), + shape: BoxShape.circle, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } + + // ============================================================ + // 操作行 + // ============================================================ + + Widget _buildActionRow(BuildContext context, ColorScheme colorScheme) { + return SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // 撤销 + IconButton( + onPressed: state.strokes.isNotEmpty + ? () => onEvent(Undo()) + : null, + icon: const Icon(Icons.undo_rounded), + tooltip: '撤销', + ), + + // 重做 + IconButton( + onPressed: state.redoStack.isNotEmpty + ? () => onEvent(Redo()) + : null, + icon: const Icon(Icons.redo_rounded), + tooltip: '重做', + ), + + // 清除 + IconButton( + onPressed: state.strokes.isNotEmpty || state.elements.isNotEmpty + ? () => onEvent(ClearCanvas()) + : null, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: '清除', + ), + + // 保存状态指示 + if (state.isDirty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8), + child: Text( + '未保存', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ) + else if (state.lastSavedAt != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8), + child: Text( + '已保存', + style: TextStyle( + fontSize: 12, + color: AppColors.success, + ), + ), + ), + ], + ), + ); + } + + // ============================================================ + // 工具函数 + // ============================================================ + + Color _parseHexColor(String hex) { + final hexStr = hex.replaceFirst('#', ''); + if (hexStr.length != 6) return const Color(0xFF2D2420); + final value = int.tryParse(hexStr, radix: 16); + if (value == null) return const Color(0xFF2D2420); + return Color(0xFF000000 + value); + } +}