feat(diary): 手写引擎 + 日记 CRUD + 同步 API (Phase F3 + B2)

Flutter 手写引擎 (Phase F3):
- stroke_model.dart: 笔画数据模型 (StrokePoint/Stroke/BrushType)
- stroke_renderer.dart: perfect_freehand 渲染管线 + 四画笔参数
- handwriting_canvas.dart: Listener 输入 + 掌心抑制 + 去抖过滤
- editor_bloc.dart: BLoC 状态管理 + 撤销/重做 (50步)

Rust 日记 CRUD + 同步 (Phase B2):
- journal_service.rs: CRUD + 软删除 + 分页列表 + 事件发布
- sync_service.rs: 版本号同步 + 冲突检测
- journal_handler.rs: 5个API端点 + utoipa注解 + 权限守卫
- sync_handler.rs: 同步API端点
- error.rs: From<DiaryError> for AppError + 8个单元测试
- 路由注册: /diary/journals + /diary/sync

验证:
- cargo check: 0 error
- cargo test: 433 测试全通过
- flutter analyze: 1 warning (unused private param)
This commit is contained in:
iven
2026-06-01 00:36:05 +08:00
parent ee5ce9bc56
commit d0653614e0
12 changed files with 1727 additions and 4 deletions

View File

@@ -0,0 +1,136 @@
// 编辑器 BLoC — 手写状态管理 + 撤销/重做
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/stroke_model.dart';
// ===== Events =====
abstract class EditorEvent {}
class BrushChanged extends EditorEvent {
final BrushType type;
final String color;
final double width;
BrushChanged({required this.type, required this.color, required this.width});
}
class StrokeCompleted extends EditorEvent {
final Stroke stroke;
StrokeCompleted(this.stroke);
}
class Undo extends EditorEvent {}
class Redo extends EditorEvent {}
class ClearCanvas extends EditorEvent {}
class StrokesLoaded extends EditorEvent {
final List<Stroke> strokes;
StrokesLoaded(this.strokes);
}
// ===== State =====
class EditorState {
final List<Stroke> strokes;
final List<Stroke> redoStack;
final BrushType brushType;
final String brushColor;
final double brushWidth;
final int maxUndoSteps;
const EditorState({
this.strokes = const [],
this.redoStack = const [],
this.brushType = BrushType.pen,
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.maxUndoSteps = 50,
});
EditorState copyWith({
List<Stroke>? strokes,
List<Stroke>? redoStack,
BrushType? brushType,
String? brushColor,
double? brushWidth,
}) =>
EditorState(
strokes: strokes ?? this.strokes,
redoStack: redoStack ?? this.redoStack,
brushType: brushType ?? this.brushType,
brushColor: brushColor ?? this.brushColor,
brushWidth: brushWidth ?? this.brushWidth,
maxUndoSteps: maxUndoSteps,
);
}
// ===== BLoC =====
class EditorBloc extends Bloc<EditorEvent, EditorState> {
EditorBloc() : super(const EditorState()) {
on<BrushChanged>(_onBrushChanged);
on<StrokeCompleted>(_onStrokeCompleted);
on<Undo>(_onUndo);
on<Redo>(_onRedo);
on<ClearCanvas>(_onClearCanvas);
on<StrokesLoaded>(_onStrokesLoaded);
}
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
emit(state.copyWith(
brushType: event.type,
brushColor: event.color,
brushWidth: event.width,
));
}
void _onStrokeCompleted(StrokeCompleted event, Emitter<EditorState> emit) {
final updatedStrokes = List<Stroke>.from(state.strokes)..add(event.stroke);
// 超过最大撤销步数时移除最旧的
if (updatedStrokes.length > state.maxUndoSteps) {
updatedStrokes.removeAt(0);
}
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: [], // 新笔画清空重做栈
));
}
void _onUndo(Undo event, Emitter<EditorState> emit) {
if (state.strokes.isEmpty) return;
final updatedStrokes = List<Stroke>.from(state.strokes);
final lastStroke = updatedStrokes.removeLast();
final updatedRedoStack = List<Stroke>.from(state.redoStack)..add(lastStroke);
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: updatedRedoStack,
));
}
void _onRedo(Redo event, Emitter<EditorState> emit) {
if (state.redoStack.isEmpty) return;
final updatedRedoStack = List<Stroke>.from(state.redoStack);
final stroke = updatedRedoStack.removeLast();
final updatedStrokes = List<Stroke>.from(state.strokes)..add(stroke);
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: updatedRedoStack,
));
}
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
emit(state.copyWith(strokes: [], redoStack: []));
}
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(strokes: event.strokes, redoStack: []));
}
}

View File

@@ -0,0 +1,242 @@
/// 手写 Canvas 主组件 — 暖记手写引擎的用户交互层。
///
/// 核心设计决策:
/// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟)
/// - 支持触控笔模式下的掌心抑制palm rejection
/// - 轻量去抖±1px 移动平均过滤,消除手指微小抖动
/// - [RepaintBoundary] 隔离重绘范围,避免影响父组件
///
/// 性能目标p99 延迟 < 16ms满足 60fps 要求)。
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'stroke_model.dart';
import 'stroke_renderer.dart';
// ============================================================
// 手写 Canvas 组件
// ============================================================
/// 手写画布,接收用户输入并实时渲染笔画。
///
/// 使用方式:
/// ```dart
/// HandwritingCanvas(
/// brushType: BrushType.pen,
/// brushColor: '#2D2420',
/// brushWidth: 3.0,
/// strokes: editorState.strokes,
/// onStrokeCompleted: (stroke) => context.read<EditorBloc>().add(
/// StrokeCompleted(stroke),
/// ),
/// )
/// ```
class HandwritingCanvas extends StatefulWidget {
const HandwritingCanvas({
super.key,
this.brushType = BrushType.pen,
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.onStrokeCompleted,
this.strokes = const [],
});
/// 当前选中的画笔类型。
final BrushType brushType;
/// 当前画笔颜色CSS 十六进制格式,如 '#2D2420')。
final String brushColor;
/// 当前画笔宽度(基准 3.0)。
final double brushWidth;
/// 笔画完成时的回调,将新笔画传递给上层(通常是 BLoC
final ValueChanged<Stroke>? onStrokeCompleted;
/// 已有的笔画列表(用于重绘历史内容)。
final List<Stroke> strokes;
@override
State<HandwritingCanvas> createState() => _HandwritingCanvasState();
}
class _HandwritingCanvasState extends State<HandwritingCanvas> {
// ============================================================
// 状态
// ============================================================
/// 正在绘制中的笔画采样点。
List<StrokePoint> _currentPoints = const [];
/// RepaintBoundary 的 key用于隔离重绘。
final GlobalKey _repaintBoundaryKey = GlobalKey();
/// UUID 生成器实例。
static const _uuid = Uuid();
// ============================================================
// 掌心抑制
// ============================================================
/// 是否启用掌心抑制。
///
/// 当设备支持触控笔 (stylus) 时启用:只响应 stylus 输入,
/// 忽略触摸 (touch) 输入,防止手掌误触。
bool _palmRejectionEnabled = false;
// ============================================================
// 去抖
// ============================================================
/// 上一个有效采样点的坐标(用于去抖计算)。
Offset? _lastAcceptedPoint;
/// 去抖阈值(像素):移动距离小于此值的点将被过滤。
static const double _debounceThreshold = 1.0;
// ============================================================
// 指针事件处理
// ============================================================
/// 指针按下:开始新笔画。
///
/// 掌心抑制逻辑:如果已检测到触控笔设备,则只响应 stylus 事件,
/// 忽略 touch 事件。首次检测到 stylus 时自动启用抑制。
void _onPointerDown(PointerDownEvent event) {
// 检测触控笔设备并启用掌心抑制
if (event.kind == PointerDeviceKind.stylus) {
_palmRejectionEnabled = true;
}
// 掌心抑制:如果已启用且当前非 stylus 输入,忽略
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
// 开始新笔画
final point = StrokePoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
_currentPoints = [point];
_lastAcceptedPoint = Offset(point.x, point.y);
}
/// 指针移动:添加采样点(带去抖过滤)。
void _onPointerMove(PointerMoveEvent event) {
if (_currentPoints.isEmpty) return;
// 掌心抑制
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
final candidate = Offset(event.localPosition.dx, event.localPosition.dy);
// 轻量去抖:移动距离 < 阈值时忽略(消除手指微抖)
if (_lastAcceptedPoint != null) {
final distance = (candidate - _lastAcceptedPoint!).distance;
if (distance < _debounceThreshold) return;
}
final point = StrokePoint(
x: candidate.dx,
y: candidate.dy,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
setState(() {
_currentPoints = [..._currentPoints, point];
});
_lastAcceptedPoint = candidate;
}
/// 指针抬起:完成笔画并通过回调传递给上层。
void _onPointerUp(PointerUpEvent event) {
if (_currentPoints.isEmpty) return;
// 掌心抑制
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
// 至少需要 2 个点才能构成有意义的笔画
if (_currentPoints.length < 2) {
_currentPoints = const [];
_lastAcceptedPoint = null;
return;
}
// 创建不可变的 Stroke 对象
final stroke = Stroke(
id: _uuid.v4(),
points: List.unmodifiable(_currentPoints),
brushType: widget.brushType,
color: widget.brushColor,
width: widget.brushWidth,
);
// 通知上层
widget.onStrokeCompleted?.call(stroke);
// 重置当前绘制状态
setState(() {
_currentPoints = const [];
_lastAcceptedPoint = null;
});
}
/// 指针取消(如来电打断):丢弃当前笔画。
void _onPointerCancel(PointerCancelEvent event) {
setState(() {
_currentPoints = const [];
_lastAcceptedPoint = null;
});
}
// ============================================================
// 构建
// ============================================================
@override
Widget build(BuildContext context) {
// 构造当前正在绘制的笔画(用于实时预览)
final currentStroke = _currentPoints.length >= 2
? Stroke(
id: '__current__', // 临时 ID不会被持久化
points: _currentPoints,
brushType: widget.brushType,
color: widget.brushColor,
width: widget.brushWidth,
)
: null;
return RepaintBoundary(
key: _repaintBoundaryKey,
child: ClipRect(
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
child: CustomPaint(
painter: StrokePainter(
completedStrokes: widget.strokes,
currentStroke: currentStroke,
),
// 占满父组件可用空间
size: Size.infinite,
),
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
// 笔画数据模型 — 手写不可变类(避免 build_runner 依赖)
import 'dart:collection';
/// 画笔类型
enum BrushType {
pen('pen'),
pencil('pencil'),
marker('marker'),
eraser('eraser');
const BrushType(this.value);
final String value;
}
/// 笔画点
class StrokePoint {
const StrokePoint({
required this.x,
required this.y,
this.pressure = 0.5,
this.timestamp = 0,
});
final double x;
final double y;
final double pressure;
final int timestamp;
StrokePoint copyWith({
double? x,
double? y,
double? pressure,
int? timestamp,
}) =>
StrokePoint(
x: x ?? this.x,
y: y ?? this.y,
pressure: pressure ?? this.pressure,
timestamp: timestamp ?? this.timestamp,
);
Map<String, dynamic> toJson() => {
'x': x,
'y': y,
'pressure': pressure,
'timestamp': timestamp,
};
factory StrokePoint.fromJson(Map<String, dynamic> json) => StrokePoint(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
timestamp: (json['timestamp'] as int?) ?? 0,
);
}
/// 笔画
class Stroke {
const Stroke({
required this.id,
required this.points,
this.brushType = BrushType.pen,
this.color = '#2D2420',
this.width = 3.0,
});
final String id;
final List<StrokePoint> points;
final BrushType brushType;
final String color;
final double width;
Stroke copyWith({
String? id,
List<StrokePoint>? points,
BrushType? brushType,
String? color,
double? width,
}) =>
Stroke(
id: id ?? this.id,
points: points ?? this.points,
brushType: brushType ?? this.brushType,
color: color ?? this.color,
width: width ?? this.width,
);
Map<String, dynamic> toJson() => {
'id': id,
'points': points.map((p) => p.toJson()).toList(),
'brushType': brushType.value,
'color': color,
'width': width,
};
factory Stroke.fromJson(Map<String, dynamic> json) => Stroke(
id: json['id'] as String,
points: UnmodifiableListView(
(json['points'] as List)
.map((p) => StrokePoint.fromJson(p as Map<String, dynamic>))
.toList(),
),
brushType: BrushType.values.firstWhere(
(b) => b.value == json['brushType'],
orElse: () => BrushType.pen,
),
color: (json['color'] as String?) ?? '#2D2420',
width: (json['width'] as num?)?.toDouble() ?? 3.0,
);
}

View File

@@ -0,0 +1,235 @@
// 笔画渲染器 — 将 StrokePoint 列表转换为 Flutter 可绘制路径。
//
// 核心流程:
// StrokePoint[] → perfect_freehand.getStroke() → Point[] (轮廓多边形)
// → buildStrokePath() → Path → CustomPainter.drawPath()
//
// 每种画笔pen/pencil/marker/eraser拥有独立的 perfect_freehand 参数,
// 产生不同的视觉效果。
import 'package:flutter/widgets.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'stroke_model.dart';
// ============================================================
// 画笔参数配置
// ============================================================
/// perfect_freehand 参数集,按画笔类型区分。
///
/// 参考设计规格 v1.2:钢笔细致、铅笔柔和、马克笔粗半透明、橡皮擦大范围。
class _BrushConfig {
final double size;
final double thinning;
final double smoothing;
final double streamline;
final bool simulatePressure;
const _BrushConfig({
required this.size,
required this.thinning,
required this.smoothing,
this.streamline = 0.5,
required this.simulatePressure,
});
}
/// 各画笔的渲染参数。
const Map<BrushType, _BrushConfig> _brushConfigs = {
/// 钢笔:中等粗细,强压感变化,模拟毛笔效果
BrushType.pen: _BrushConfig(
size: 8,
thinning: 0.7,
smoothing: 0.5,
simulatePressure: true,
),
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
BrushType.pencil: _BrushConfig(
size: 4,
thinning: 0.3,
smoothing: 0.7,
simulatePressure: true,
),
/// 马克笔:粗线,几乎无压感变化,不模拟压力
BrushType.marker: _BrushConfig(
size: 16,
thinning: 0.1,
smoothing: 0.5,
simulatePressure: false,
),
/// 橡皮擦:最大范围,无压感变化
BrushType.eraser: _BrushConfig(
size: 32,
thinning: 0,
smoothing: 0.5,
simulatePressure: false,
),
};
// ============================================================
// 点转换工具函数
// ============================================================
/// 将 [StrokePoint] 列表转换为 perfect_freehand 轮廓点列表。
///
/// [brushType] 决定渲染参数,[width] 作为乘数影响最终笔画大小。
/// 当 [points] 少于 2 个时返回空列表(无法构成笔画)。
List<Offset> pointsToOutline(
List<StrokePoint> points,
BrushType brushType,
double width,
) {
if (points.length < 2) return const [];
final config = _brushConfigs[brushType]!;
final widthMultiplier = width / 3.0; // 3.0 是默认宽度,缩放到用户选择
// 转换为 perfect_freehand 的 Point 格式
final pfPoints = points
.map((p) => pf.Point(p.x, p.y, p.pressure))
.toList(growable: false);
// 调用 getStroke 获取轮廓多边形
final outline = pf.getStroke(
pfPoints,
size: config.size * widthMultiplier,
thinning: config.thinning,
smoothing: config.smoothing,
streamline: config.streamline,
simulatePressure: config.simulatePressure,
isComplete: true, // 已完成笔画,启用端点处理
);
// 转换为 Flutter Offset
return outline.map((p) => Offset(p.x, p.y)).toList(growable: false);
}
// ============================================================
// 路径构建
// ============================================================
/// 将轮廓点列表构建为 Flutter [Path]。
///
/// 使用 [Path.addPolygon] 直接构建闭合多边形,
/// 比 lineTo 逐点连接更高效,且自动闭合。
Path buildStrokePath(List<Offset> outlinePoints) {
if (outlinePoints.isEmpty) return Path();
final path = Path();
path.addPolygon(outlinePoints, true); // true = 自动闭合
return path;
}
// ============================================================
// 颜色解析工具
// ============================================================
/// 将 CSS 十六进制颜色字符串 (#RRGGBB) 解析为 Flutter [Color]。
///
/// 如果解析失败,回退到默认的深色文字色 #2D2420。
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);
}
// ============================================================
// 笔画绘制器
// ============================================================
/// 自定义 [CustomPainter],负责将所有笔画渲染到 Canvas。
///
/// 接收 [completedStrokes](已完成笔画列表)和可选的 [currentStroke]
/// (正在绘制中的笔画),分别渲染。
///
/// 渲染策略:
/// - 钢笔/铅笔:正常不透明绘制
/// - 马克笔:半透明 (0.4 opacity) 模拟荧光笔效果
/// - 橡皮擦:使用 [BlendMode.dstOut] 擦除底层内容
class StrokePainter extends CustomPainter {
final List<Stroke> completedStrokes;
final Stroke? currentStroke;
StrokePainter({
required this.completedStrokes,
this.currentStroke,
});
@override
void paint(Canvas canvas, Size size) {
// 先绘制已完成的笔画
for (final stroke in completedStrokes) {
_drawStroke(canvas, stroke);
}
// 再绘制正在进行的笔画(覆盖在已完成笔画之上)
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
/// 渲染单条笔画。
void _drawStroke(Canvas canvas, Stroke stroke) {
final outlinePoints = pointsToOutline(
stroke.points,
stroke.brushType,
stroke.width,
);
if (outlinePoints.isEmpty) return;
final path = buildStrokePath(outlinePoints);
// 根据画笔类型选择不同的绘制策略
final paint = _createPaint(stroke);
canvas.drawPath(path, paint);
}
/// 根据画笔类型创建对应的 [Paint] 对象。
Paint _createPaint(Stroke stroke) {
final color = _parseHexColor(stroke.color);
switch (stroke.brushType) {
case BrushType.pen:
case BrushType.pencil:
// 钢笔和铅笔:不透明实心绘制
return Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
case BrushType.marker:
// 马克笔:半透明模拟荧光笔
return Paint()
..color = color.withValues(alpha: 0.4)
..style = PaintingStyle.fill
..isAntiAlias = true;
case BrushType.eraser:
// 橡皮擦:使用 dstOut 混合模式擦除底层像素
return Paint()
..color = const Color(0xFFFFFFFF) // 颜色无关紧要blendMode 决定效果
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut
..isAntiAlias = true;
}
}
/// 判断是否需要重绘。
///
/// 始终比较引用:当 strokes 列表或 currentStroke 发生变化时重绘。
/// 这是最安全的策略因为笔画数据是不可变的freezed
/// 引用变化意味着内容一定变化。
@override
bool shouldRepaint(StrokePainter oldDelegate) {
return !identical(completedStrokes, oldDelegate.completedStrokes) ||
!identical(currentStroke, oldDelegate.currentStroke);
}
}