Files
nj/app/lib/features/editor/widgets/editor_toolbar.dart
iven 482eb244d5 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)
2026-06-01 01:45:35 +08:00

296 lines
9.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 编辑器工具栏 — 底部工具面板
//
// 三段式布局:
// - 工具选择行(画笔/选择/文字/贴纸/照片)
// - 工具选项行(颜色/大小 — 根据当前工具动态变化)
// - 操作行(撤销/重做/清除)
//
// 设计规范:触摸目标 ≥ 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);
}
}