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