444 lines
12 KiB
Dart
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;
|
|
}
|
|
}
|