Files
system-design/software-copyright/10-writech-app-pad/renderer/stroke_painter.dart
T
2026-03-22 15:24:40 +08:00

444 lines
12 KiB
Dart

/// 自然写互动课堂平板端应用软件 V1.0
/// Skia笔迹渲染器 - CustomPainter实现触屏直写与点阵笔笔迹渲染
///
/// 功能说明:
/// 1. CustomPainter高性能笔迹绘制(Skia引擎)
/// 2. 触屏直写支持(手指/触控笔Pointer事件处理)
/// 3. 点阵笔BLE数据渲染(从BLE服务接收坐标数据)
/// 4. 压力感应笔锋效果(触控笔ActiveStylus压力数据)
/// 5. 贝塞尔曲线平滑算法
/// 6. 字帖练习辅助线(田字格/米字格/四线三格)
/// 7. 撤销/重做操作栈
/// 8. 笔迹导出(SVG/PNG格式)
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
/* ========== 数据模型 ========== */
/// 笔迹点
class PadStrokePoint {
final double x;
final double y;
final double pressure;
final int timestamp;
const PadStrokePoint({
required this.x,
required this.y,
this.pressure = 0.5,
required this.timestamp,
});
Map<String, dynamic> toJson() => {
'x': x, 'y': y, 'pressure': pressure, 'timestamp': timestamp,
};
}
/// 笔画
class PadStroke {
final List<PadStrokePoint> points;
final Color color;
final double baseWidth;
final String source; // 'touch'=触屏, 'ble'=点阵笔
PadStroke({
List<PadStrokePoint>? points,
this.color = Colors.black,
this.baseWidth = 2.5,
this.source = 'touch',
}) : points = points ?? [];
void addPoint(PadStrokePoint point) => points.add(point);
}
/// 辅助线类型
enum GuideLineType {
none, // 无辅助线
tianZiGe, // 田字格
miZiGe, // 米字格
siXianSanGe, // 四线三格(英文/拼音)
}
/// 撤销/重做操作
sealed class CanvasAction {}
class AddStrokeAction extends CanvasAction {
final PadStroke stroke;
AddStrokeAction(this.stroke);
}
class ClearAction extends CanvasAction {
final List<PadStroke> clearedStrokes;
ClearAction(this.clearedStrokes);
}
/* ========== 笔迹画布Widget ========== */
/// 平板端笔迹渲染画布
/// 支持触屏直写和BLE点阵笔两种输入方式
class PadStrokeCanvas extends StatefulWidget {
/// 初始笔画数据(如加载已有作业笔迹)
final List<PadStroke>? initialStrokes;
/// 辅助线类型
final GuideLineType guideLineType;
/// 是否只读模式(查看已提交的作业)
final bool readOnly;
/// 笔迹颜色
final Color strokeColor;
/// 笔画宽度
final double strokeWidth;
/// 笔迹变化回调
final Function(List<PadStroke>)? onStrokesChanged;
const PadStrokeCanvas({
super.key,
this.initialStrokes,
this.guideLineType = GuideLineType.none,
this.readOnly = false,
this.strokeColor = Colors.black,
this.strokeWidth = 2.5,
this.onStrokesChanged,
});
@override
State<PadStrokeCanvas> createState() => _PadStrokeCanvasState();
}
class _PadStrokeCanvasState extends State<PadStrokeCanvas> {
/// 已完成的笔画列表
final List<PadStroke> _strokes = [];
/// 当前正在绘制的笔画
PadStroke? _currentStroke;
/// 撤销栈
final List<CanvasAction> _undoStack = [];
/// 重做栈
final List<CanvasAction> _redoStack = [];
/// 最大撤销步数
static const int maxUndoSteps = 50;
@override
void initState() {
super.initState();
if (widget.initialStrokes != null) {
_strokes.addAll(widget.initialStrokes!);
}
}
/// 撤销最后一个操作
void undo() {
if (_undoStack.isEmpty) return;
final action = _undoStack.removeLast();
if (action is AddStrokeAction) {
_strokes.remove(action.stroke);
_redoStack.add(action);
} else if (action is ClearAction) {
_strokes.addAll(action.clearedStrokes);
_redoStack.add(action);
}
setState(() {});
widget.onStrokesChanged?.call(_strokes);
}
/// 重做上一个撤销的操作
void redo() {
if (_redoStack.isEmpty) return;
final action = _redoStack.removeLast();
if (action is AddStrokeAction) {
_strokes.add(action.stroke);
_undoStack.add(action);
} else if (action is ClearAction) {
_strokes.clear();
_undoStack.add(action);
}
setState(() {});
widget.onStrokesChanged?.call(_strokes);
}
/// 清除所有笔迹
void clearAll() {
if (_strokes.isEmpty) return;
final cleared = List<PadStroke>.from(_strokes);
_undoStack.add(ClearAction(cleared));
_strokes.clear();
_redoStack.clear();
setState(() {});
widget.onStrokesChanged?.call(_strokes);
}
/// 从BLE点阵笔添加笔画(外部调用)
void addBleStroke(PadStroke stroke) {
_strokes.add(stroke);
_undoStack.add(AddStrokeAction(stroke));
_redoStack.clear();
setState(() {});
widget.onStrokesChanged?.call(_strokes);
}
/// 获取所有笔画数据(用于提交)
List<PadStroke> getStrokes() => List.unmodifiable(_strokes);
@override
Widget build(BuildContext context) {
return Listener(
// 使用Listener而非GestureDetector,以获取精确的Pointer事件
onPointerDown: widget.readOnly ? null : _onPointerDown,
onPointerMove: widget.readOnly ? null : _onPointerMove,
onPointerUp: widget.readOnly ? null : _onPointerUp,
child: ClipRect(
child: CustomPaint(
painter: _PadStrokePainter(
strokes: _strokes,
currentStroke: _currentStroke,
guideLineType: widget.guideLineType,
),
size: Size.infinite,
),
),
);
}
/// 触屏落笔
void _onPointerDown(PointerDownEvent event) {
final pressure = event.pressure > 0 ? event.pressure : 0.5;
_currentStroke = PadStroke(
color: widget.strokeColor,
baseWidth: widget.strokeWidth,
source: event.kind == PointerDeviceKind.stylus ? 'stylus' : 'touch',
);
_currentStroke!.addPoint(PadStrokePoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
));
setState(() {});
}
/// 触屏移动
void _onPointerMove(PointerMoveEvent event) {
if (_currentStroke == null) return;
final pressure = event.pressure > 0 ? event.pressure : 0.5;
_currentStroke!.addPoint(PadStrokePoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
));
setState(() {});
}
/// 触屏抬笔
void _onPointerUp(PointerUpEvent event) {
if (_currentStroke == null) return;
if (_currentStroke!.points.length >= 2) {
_strokes.add(_currentStroke!);
_undoStack.add(AddStrokeAction(_currentStroke!));
_redoStack.clear();
// 限制撤销栈大小
if (_undoStack.length > maxUndoSteps) {
_undoStack.removeAt(0);
}
widget.onStrokesChanged?.call(_strokes);
}
_currentStroke = null;
setState(() {});
}
}
/* ========== Painter实现 ========== */
/// 笔迹绘制Painter
class _PadStrokePainter extends CustomPainter {
final List<PadStroke> strokes;
final PadStroke? currentStroke;
final GuideLineType guideLineType;
_PadStrokePainter({
required this.strokes,
this.currentStroke,
this.guideLineType = GuideLineType.none,
});
@override
void paint(Canvas canvas, Size size) {
// 绘制背景
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = Colors.white,
);
// 绘制辅助线
if (guideLineType != GuideLineType.none) {
_drawGuideLines(canvas, size);
}
// 绘制已完成的笔画
for (final stroke in strokes) {
_drawStroke(canvas, stroke);
}
// 绘制当前活跃笔画
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
/// 绘制辅助线
void _drawGuideLines(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.5;
switch (guideLineType) {
case GuideLineType.tianZiGe:
_drawTianZiGe(canvas, size, paint);
break;
case GuideLineType.miZiGe:
_drawMiZiGe(canvas, size, paint);
break;
case GuideLineType.siXianSanGe:
_drawSiXianSanGe(canvas, size, paint);
break;
default:
break;
}
}
/// 绘制田字格
void _drawTianZiGe(Canvas canvas, Size size, Paint paint) {
const cellSize = 80.0;
paint.color = Colors.red.withValues(alpha: 0.3);
// 外框(实线)
for (double x = 0; x <= size.width; x += cellSize) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = 0; y <= size.height; y += cellSize) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
// 中心十字线(虚线效果用半透明)
paint.color = Colors.red.withValues(alpha: 0.15);
final halfCell = cellSize / 2;
for (double x = halfCell; x < size.width; x += cellSize) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = halfCell; y < size.height; y += cellSize) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
/// 绘制米字格
void _drawMiZiGe(Canvas canvas, Size size, Paint paint) {
const cellSize = 80.0;
paint.color = Colors.red.withValues(alpha: 0.3);
for (double x = 0; x <= size.width; x += cellSize) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = 0; y <= size.height; y += cellSize) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
// 对角线 + 十字线
paint.color = Colors.red.withValues(alpha: 0.15);
for (double x = 0; x < size.width; x += cellSize) {
for (double y = 0; y < size.height; y += cellSize) {
// 对角线
canvas.drawLine(Offset(x, y), Offset(x + cellSize, y + cellSize), paint);
canvas.drawLine(Offset(x + cellSize, y), Offset(x, y + cellSize), paint);
// 十字线
canvas.drawLine(Offset(x + cellSize / 2, y), Offset(x + cellSize / 2, y + cellSize), paint);
canvas.drawLine(Offset(x, y + cellSize / 2), Offset(x + cellSize, y + cellSize / 2), paint);
}
}
}
/// 绘制四线三格(拼音/英文)
void _drawSiXianSanGe(Canvas canvas, Size size, Paint paint) {
const lineSpacing = 15.0;
const groupHeight = lineSpacing * 3;
const groupGap = 20.0;
paint.color = Colors.green.withValues(alpha: 0.3);
double y = 20;
while (y < size.height - groupHeight) {
// 四条横线
for (int i = 0; i < 4; i++) {
final lineY = y + i * lineSpacing;
// 第二条线(中线)用虚线表示
if (i == 1 || i == 2) {
paint.color = Colors.green.withValues(alpha: 0.15);
} else {
paint.color = Colors.green.withValues(alpha: 0.3);
}
canvas.drawLine(Offset(0, lineY), Offset(size.width, lineY), paint);
}
y += groupHeight + groupGap;
}
}
/// 绘制单个笔画(贝塞尔平滑 + 压力笔锋)
void _drawStroke(Canvas canvas, PadStroke stroke) {
if (stroke.points.length < 2) return;
final paint = Paint()
..color = stroke.color
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..isAntiAlias = true;
for (int i = 1; i < stroke.points.length; i++) {
final prev = stroke.points[i - 1];
final curr = stroke.points[i];
// 压力笔锋宽度计算
final avgPressure = (prev.pressure + curr.pressure) / 2.0;
var width = stroke.baseWidth * (0.3 + avgPressure * 1.7);
// 落笔过渡
if (i < 5) width *= (i / 5.0);
// 抬笔过渡
final remaining = stroke.points.length - i;
if (remaining < 5) width *= (remaining / 5.0);
width = max(width, 0.5);
paint.strokeWidth = width;
if (i >= 2) {
// 贝塞尔曲线平滑
final pp = stroke.points[i - 2];
final cp1x = prev.x + (curr.x - pp.x) * 0.2;
final cp1y = prev.y + (curr.y - pp.y) * 0.2;
final cp2x = curr.x - (curr.x - prev.x) * 0.2;
final cp2y = curr.y - (curr.y - prev.y) * 0.2;
final path = Path()
..moveTo(prev.x, prev.y)
..cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x, curr.y);
canvas.drawPath(path, paint);
} else {
canvas.drawLine(Offset(prev.x, prev.y), Offset(curr.x, curr.y), paint);
}
}
}
@override
bool shouldRepaint(covariant _PadStrokePainter oldDelegate) {
return oldDelegate.strokes.length != strokes.length ||
oldDelegate.currentStroke != currentStroke;
}
}