新增组件: - 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)
296 lines
9.5 KiB
Dart
296 lines
9.5 KiB
Dart
// 编辑器工具栏 — 底部工具面板
|
||
//
|
||
// 三段式布局:
|
||
// - 工具选择行(画笔/选择/文字/贴纸/照片)
|
||
// - 工具选项行(颜色/大小 — 根据当前工具动态变化)
|
||
// - 操作行(撤销/重做/清除)
|
||
//
|
||
// 设计规范:触摸目标 ≥ 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);
|
||
}
|
||
}
|