1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged Stream 变更通知,HomeBloc 订阅后自动刷新 2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件, 工具栏检测已激活工具时发出重新激活信号 3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数 (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35) 4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制 半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘 5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势 替换 Pan 手势,支持双指缩放和旋转
282 lines
8.7 KiB
Dart
282 lines
8.7 KiB
Dart
// 可拖拽元素组件 — 日记页面中的贴纸/照片/文字交互层
|
|
//
|
|
// 支持操作:
|
|
// - 拖拽移动(单指)
|
|
// - 双指缩放
|
|
// - 双指旋转
|
|
// - 单击选中/取消选中
|
|
// - 选中时显示边框和删除按钮
|
|
|
|
import 'dart:io';
|
|
|
|
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 void Function(String id, double w, double h)? onResized;
|
|
final void Function(String id, double rotation)? onRotated;
|
|
final ValueChanged<String> onDeleted;
|
|
|
|
const DraggableElement({
|
|
super.key,
|
|
required this.element,
|
|
this.isSelected = false,
|
|
required this.onTap,
|
|
required this.onMoved,
|
|
this.onResized,
|
|
this.onRotated,
|
|
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;
|
|
|
|
// Scale 手势状态
|
|
double _baseWidth = 0;
|
|
double _baseHeight = 0;
|
|
double _baseRotation = 0;
|
|
|
|
@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(
|
|
// 缩放开始 — 记录基准值
|
|
onScaleStart: (details) {
|
|
_baseWidth = _width;
|
|
_baseHeight = _height;
|
|
_baseRotation = _rotation;
|
|
},
|
|
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
|
|
onScaleUpdate: (details) {
|
|
setState(() {
|
|
// 拖拽(单指和双指都支持)
|
|
_x += details.focalPointDelta.dx;
|
|
_y += details.focalPointDelta.dy;
|
|
|
|
// 双指缩放 + 旋转
|
|
if (details.pointerCount >= 2) {
|
|
final newW = (_baseWidth * details.scale).clamp(40.0, 400.0);
|
|
final newH = (_baseHeight * details.scale).clamp(40.0, 400.0);
|
|
_width = newW;
|
|
_height = newH;
|
|
_rotation = _baseRotation + details.rotation;
|
|
}
|
|
});
|
|
widget.onMoved(widget.element.id, _x, _y);
|
|
if (details.pointerCount >= 2) {
|
|
widget.onResized?.call(widget.element.id, _width, _height);
|
|
widget.onRotated?.call(widget.element.id, _rotation);
|
|
}
|
|
},
|
|
onScaleEnd: (_) {
|
|
// 确保最终位置已通知
|
|
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:
|
|
final text = element.content['text'] as String? ?? '';
|
|
final fontSize = (element.content['fontSize'] as num?)?.toDouble() ?? 18.0;
|
|
final fontColor = element.content['fontColor'] as String? ?? '#2D2420';
|
|
final color = _parseColor(fontColor);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontSize: fontSize,
|
|
color: color,
|
|
fontFamily: 'NotoSansSC',
|
|
),
|
|
maxLines: null,
|
|
softWrap: true,
|
|
),
|
|
);
|
|
|
|
case ElementType.sticker:
|
|
return Container(
|
|
color: Colors.transparent,
|
|
alignment: Alignment.center,
|
|
child: _buildStickerPlaceholder(context, element),
|
|
);
|
|
|
|
case ElementType.image:
|
|
final filePath = element.content['filePath'] as String?;
|
|
if (filePath != null && filePath.isNotEmpty) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Image.file(
|
|
File(filePath),
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (_, __, ___) => _buildImagePlaceholder(),
|
|
),
|
|
);
|
|
}
|
|
return _buildImagePlaceholder();
|
|
|
|
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);
|
|
}
|
|
|
|
/// 图片加载失败时的占位符
|
|
Widget _buildImagePlaceholder() {
|
|
return Container(
|
|
width: 160,
|
|
height: 120,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade200,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: const Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.broken_image_outlined, color: Colors.grey),
|
|
SizedBox(height: 4),
|
|
Text('图片加载失败', style: TextStyle(color: Colors.grey, fontSize: 11)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|