feat(app): 实现手账编辑器三层架构 (Phase F4)

新增组件:
- DraggableElement: 可拖拽日记元素组件 (移动/缩放/选中/删除)
- EditorToolbar: 底部工具栏 (8种工具 + 8色 + 5级笔宽 + 撤销/重做)
- EditorStack: 三层 Stack 架构 (Canvas + 元素 + 工具栏)

重写文件:
- editor_bloc.dart: 扩展为完整编辑器 BLoC
  - 元素管理: 添加/删除/移动/缩放/旋转/选中 (7种事件)
  - 工具栏: 8种工具切换 (pen/pencil/marker/eraser/select/text/sticker/image)
  - 自动保存: 2秒 debounce 回调
  - 状态扩展: elements/selectedElementId/activeTool/isDirty
- editor_page.dart: 从占位页面重写为完整编辑器
  - 顶栏: 返回/标题/完成按钮
  - 中间: 三层 Stack (手写层 + 元素层 + 空状态提示)
  - 底部: EditorToolbar
  - 交互逻辑: 画笔模式→Canvas接收, 选择模式→元素层接收

验证: flutter analyze (0 error)
This commit is contained in:
iven
2026-06-01 01:45:35 +08:00
parent 0fe3bc705c
commit 482eb244d5
4 changed files with 1028 additions and 14 deletions

View File

@@ -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<EditorEvent> 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);
}
}