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:
@@ -1,12 +1,27 @@
|
|||||||
// 编辑器 BLoC — 手写状态管理 + 撤销/重做
|
// 编辑器 BLoC — 手写状态管理 + 元素管理 + 撤销/重做 + 自动保存
|
||||||
|
//
|
||||||
|
// 状态机:
|
||||||
|
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
|
||||||
|
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
|
||||||
|
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
|
||||||
|
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../widgets/stroke_model.dart';
|
import '../widgets/stroke_model.dart';
|
||||||
|
import '../../../data/models/journal_element.dart';
|
||||||
|
|
||||||
// ===== Events =====
|
// ============================================================
|
||||||
|
// 事件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 编辑器事件基类
|
||||||
abstract class EditorEvent {}
|
abstract class EditorEvent {}
|
||||||
|
|
||||||
|
// --- 手写事件 ---
|
||||||
|
|
||||||
class BrushChanged extends EditorEvent {
|
class BrushChanged extends EditorEvent {
|
||||||
final BrushType type;
|
final BrushType type;
|
||||||
final String color;
|
final String color;
|
||||||
@@ -30,9 +45,90 @@ class StrokesLoaded extends EditorEvent {
|
|||||||
StrokesLoaded(this.strokes);
|
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<JournalElement> elements;
|
||||||
|
ElementsLoaded(this.elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 编辑器工具枚举
|
||||||
|
enum EditorTool {
|
||||||
|
pen, // 钢笔
|
||||||
|
pencil, // 铅笔
|
||||||
|
marker, // 马克笔
|
||||||
|
eraser, // 橡皮擦
|
||||||
|
select, // 选择/移动元素
|
||||||
|
text, // 文字输入
|
||||||
|
sticker, // 贴纸
|
||||||
|
image, // 照片
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑器状态
|
||||||
class EditorState {
|
class EditorState {
|
||||||
|
// 手写层
|
||||||
final List<Stroke> strokes;
|
final List<Stroke> strokes;
|
||||||
final List<Stroke> redoStack;
|
final List<Stroke> redoStack;
|
||||||
final BrushType brushType;
|
final BrushType brushType;
|
||||||
@@ -40,6 +136,17 @@ class EditorState {
|
|||||||
final double brushWidth;
|
final double brushWidth;
|
||||||
final int maxUndoSteps;
|
final int maxUndoSteps;
|
||||||
|
|
||||||
|
// 元素层
|
||||||
|
final List<JournalElement> elements;
|
||||||
|
final String? selectedElementId;
|
||||||
|
|
||||||
|
// 工具栏
|
||||||
|
final EditorTool activeTool;
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
final bool isDirty;
|
||||||
|
final DateTime? lastSavedAt;
|
||||||
|
|
||||||
const EditorState({
|
const EditorState({
|
||||||
this.strokes = const [],
|
this.strokes = const [],
|
||||||
this.redoStack = const [],
|
this.redoStack = const [],
|
||||||
@@ -47,6 +154,11 @@ class EditorState {
|
|||||||
this.brushColor = '#2D2420',
|
this.brushColor = '#2D2420',
|
||||||
this.brushWidth = 3.0,
|
this.brushWidth = 3.0,
|
||||||
this.maxUndoSteps = 50,
|
this.maxUndoSteps = 50,
|
||||||
|
this.elements = const [],
|
||||||
|
this.selectedElementId,
|
||||||
|
this.activeTool = EditorTool.pen,
|
||||||
|
this.isDirty = false,
|
||||||
|
this.lastSavedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
EditorState copyWith({
|
EditorState copyWith({
|
||||||
@@ -55,6 +167,12 @@ class EditorState {
|
|||||||
BrushType? brushType,
|
BrushType? brushType,
|
||||||
String? brushColor,
|
String? brushColor,
|
||||||
double? brushWidth,
|
double? brushWidth,
|
||||||
|
List<JournalElement>? elements,
|
||||||
|
String? selectedElementId,
|
||||||
|
bool clearSelection = false,
|
||||||
|
EditorTool? activeTool,
|
||||||
|
bool? isDirty,
|
||||||
|
DateTime? lastSavedAt,
|
||||||
}) =>
|
}) =>
|
||||||
EditorState(
|
EditorState(
|
||||||
strokes: strokes ?? this.strokes,
|
strokes: strokes ?? this.strokes,
|
||||||
@@ -63,21 +181,75 @@ class EditorState {
|
|||||||
brushColor: brushColor ?? this.brushColor,
|
brushColor: brushColor ?? this.brushColor,
|
||||||
brushWidth: brushWidth ?? this.brushWidth,
|
brushWidth: brushWidth ?? this.brushWidth,
|
||||||
maxUndoSteps: maxUndoSteps,
|
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<EditorEvent, EditorState> {
|
class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||||
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<BrushChanged>(_onBrushChanged);
|
on<BrushChanged>(_onBrushChanged);
|
||||||
on<StrokeCompleted>(_onStrokeCompleted);
|
on<StrokeCompleted>(_onStrokeCompleted);
|
||||||
on<Undo>(_onUndo);
|
on<Undo>(_onUndo);
|
||||||
on<Redo>(_onRedo);
|
on<Redo>(_onRedo);
|
||||||
on<ClearCanvas>(_onClearCanvas);
|
on<ClearCanvas>(_onClearCanvas);
|
||||||
on<StrokesLoaded>(_onStrokesLoaded);
|
on<StrokesLoaded>(_onStrokesLoaded);
|
||||||
|
|
||||||
|
// 元素事件
|
||||||
|
on<ElementAdded>(_onElementAdded);
|
||||||
|
on<ElementRemoved>(_onElementRemoved);
|
||||||
|
on<ElementMoved>(_onElementMoved);
|
||||||
|
on<ElementResized>(_onElementResized);
|
||||||
|
on<ElementRotated>(_onElementRotated);
|
||||||
|
on<ElementSelected>(_onElementSelected);
|
||||||
|
on<ElementsLoaded>(_onElementsLoaded);
|
||||||
|
|
||||||
|
// 工具栏事件
|
||||||
|
on<ToolChanged>(_onToolChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 手写事件处理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
|
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
brushType: event.type,
|
brushType: event.type,
|
||||||
@@ -89,15 +261,16 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
|||||||
void _onStrokeCompleted(StrokeCompleted event, Emitter<EditorState> emit) {
|
void _onStrokeCompleted(StrokeCompleted event, Emitter<EditorState> emit) {
|
||||||
final updatedStrokes = List<Stroke>.from(state.strokes)..add(event.stroke);
|
final updatedStrokes = List<Stroke>.from(state.strokes)..add(event.stroke);
|
||||||
|
|
||||||
// 超过最大撤销步数时移除最旧的
|
|
||||||
if (updatedStrokes.length > state.maxUndoSteps) {
|
if (updatedStrokes.length > state.maxUndoSteps) {
|
||||||
updatedStrokes.removeAt(0);
|
updatedStrokes.removeAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
strokes: updatedStrokes,
|
strokes: updatedStrokes,
|
||||||
redoStack: [], // 新笔画清空重做栈
|
redoStack: [],
|
||||||
|
isDirty: true,
|
||||||
));
|
));
|
||||||
|
_scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onUndo(Undo event, Emitter<EditorState> emit) {
|
void _onUndo(Undo event, Emitter<EditorState> emit) {
|
||||||
@@ -110,7 +283,9 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
|||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
strokes: updatedStrokes,
|
strokes: updatedStrokes,
|
||||||
redoStack: updatedRedoStack,
|
redoStack: updatedRedoStack,
|
||||||
|
isDirty: true,
|
||||||
));
|
));
|
||||||
|
_scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onRedo(Redo event, Emitter<EditorState> emit) {
|
void _onRedo(Redo event, Emitter<EditorState> emit) {
|
||||||
@@ -123,14 +298,113 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
|||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
strokes: updatedStrokes,
|
strokes: updatedStrokes,
|
||||||
redoStack: updatedRedoStack,
|
redoStack: updatedRedoStack,
|
||||||
|
isDirty: true,
|
||||||
));
|
));
|
||||||
|
_scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
|
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
|
||||||
emit(state.copyWith(strokes: [], redoStack: []));
|
emit(state.copyWith(
|
||||||
|
strokes: [],
|
||||||
|
redoStack: [],
|
||||||
|
isDirty: true,
|
||||||
|
));
|
||||||
|
_scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
|
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
|
||||||
emit(state.copyWith(strokes: event.strokes, redoStack: []));
|
emit(state.copyWith(strokes: event.strokes, redoStack: []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 元素事件处理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
void _onElementAdded(ElementAdded event, Emitter<EditorState> emit) {
|
||||||
|
final updated = List<JournalElement>.from(state.elements)..add(event.element);
|
||||||
|
emit(state.copyWith(
|
||||||
|
elements: updated,
|
||||||
|
selectedElementId: event.element.id,
|
||||||
|
isDirty: true,
|
||||||
|
));
|
||||||
|
_scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onElementRemoved(ElementRemoved event, Emitter<EditorState> emit) {
|
||||||
|
final updated = List<JournalElement>.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<EditorState> 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<EditorState> 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<EditorState> 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<EditorState> emit) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
selectedElementId: event.elementId,
|
||||||
|
clearSelection: event.elementId == null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
|
||||||
|
emit(state.copyWith(elements: event.elements));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具栏事件处理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
void _onToolChanged(ToolChanged event, Emitter<EditorState> 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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
class EditorPage extends StatelessWidget {
|
||||||
final String? journalId;
|
final String? journalId;
|
||||||
|
|
||||||
@@ -7,14 +28,214 @@ class EditorPage extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
body: Center(
|
backgroundColor: colorScheme.surface,
|
||||||
child: Text(
|
body: SafeArea(
|
||||||
journalId != null
|
child: Column(
|
||||||
? '编辑日记 ($journalId) - 占位页面'
|
children: [
|
||||||
: '新建日记 - 占位页面',
|
// 顶栏
|
||||||
|
_buildTopBar(context),
|
||||||
|
|
||||||
|
// 编辑区域(三层 Stack)
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<EditorBloc, EditorState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return _EditorStack(state: state);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 底部工具栏
|
||||||
|
BlocBuilder<EditorBloc, EditorState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return EditorToolbar(
|
||||||
|
state: state,
|
||||||
|
onEvent: (event) => context.read<EditorBloc>().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<EditorBloc>().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<JournalElement>.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<EditorBloc>().add(ElementSelected(id));
|
||||||
|
},
|
||||||
|
onMoved: (id, x, y) {
|
||||||
|
context.read<EditorBloc>().add(ElementMoved(
|
||||||
|
elementId: id,
|
||||||
|
positionX: x,
|
||||||
|
positionY: y,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
onDeleted: (id) {
|
||||||
|
context.read<EditorBloc>().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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
224
app/lib/features/editor/widgets/draggable_element.dart
Normal file
224
app/lib/features/editor/widgets/draggable_element.dart
Normal file
@@ -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<String> onTap;
|
||||||
|
final void Function(String id, double x, double y) onMoved;
|
||||||
|
final ValueChanged<String> onDeleted;
|
||||||
|
|
||||||
|
const DraggableElement({
|
||||||
|
super.key,
|
||||||
|
required this.element,
|
||||||
|
this.isSelected = false,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onMoved,
|
||||||
|
required this.onDeleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DraggableElement> createState() => _DraggableElementState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraggableElementState extends State<DraggableElement> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
295
app/lib/features/editor/widgets/editor_toolbar.dart
Normal file
295
app/lib/features/editor/widgets/editor_toolbar.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user