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,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<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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user