Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
709
lib/widgets/ink_canvas.dart
Normal file
709
lib/widgets/ink_canvas.dart
Normal file
@@ -0,0 +1,709 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
|
||||
/// Controls whether the canvas accepts drawing input or passes events through.
|
||||
enum InteractionMode { draw, navigate }
|
||||
|
||||
class InkCanvas extends StatefulWidget {
|
||||
final List<InkStroke> strokes;
|
||||
final void Function(InkStroke stroke)? onStrokeComplete;
|
||||
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
||||
final PenTool tool;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final StabilizationLevel stabilizationLevel;
|
||||
final bool filled;
|
||||
final InteractionMode interactionMode;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
const InkCanvas({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.onStrokeComplete,
|
||||
this.onErase,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 2.0,
|
||||
this.pressureCurve = PressureCurve.linear,
|
||||
this.stabilizationLevel = StabilizationLevel.none,
|
||||
this.filled = false,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InkCanvas> createState() => _InkCanvasState();
|
||||
}
|
||||
|
||||
class _InkCanvasState extends State<InkCanvas> {
|
||||
final List<InkPoint> _currentPoints = [];
|
||||
bool _isDrawing = false;
|
||||
PenTool? _activeTool;
|
||||
StrokeStabilizer? _stabilizer;
|
||||
|
||||
/// Start point for shape tools.
|
||||
InkPoint? _shapeStart;
|
||||
|
||||
/// Whether the active tool is a shape tool (needs only 2 points).
|
||||
bool get _isShapeTool {
|
||||
final t = _activeTool ?? widget.tool;
|
||||
return t == PenTool.rectangle ||
|
||||
t == PenTool.ellipse ||
|
||||
t == PenTool.line ||
|
||||
t == PenTool.arrow;
|
||||
}
|
||||
|
||||
/// Whether the active tool is the text tool.
|
||||
bool get _isTextTool {
|
||||
return (_activeTool ?? widget.tool) == PenTool.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(InkCanvas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.stabilizationLevel != widget.stabilizationLevel) {
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
}
|
||||
|
||||
InputDeviceKind _mapKind(PointerDeviceKind kind) {
|
||||
switch (kind) {
|
||||
case PointerDeviceKind.touch:
|
||||
return InputDeviceKind.touch;
|
||||
case PointerDeviceKind.mouse:
|
||||
return InputDeviceKind.mouse;
|
||||
case PointerDeviceKind.stylus:
|
||||
return InputDeviceKind.stylus;
|
||||
case PointerDeviceKind.invertedStylus:
|
||||
return InputDeviceKind.invertedStylus;
|
||||
case PointerDeviceKind.trackpad:
|
||||
return InputDeviceKind.trackpad;
|
||||
case PointerDeviceKind.unknown:
|
||||
return InputDeviceKind.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
InkPoint _makePoint(PointerEvent event) {
|
||||
return InkPoint(
|
||||
x: event.localPosition.dx,
|
||||
y: event.localPosition.dy,
|
||||
pressure: event.pressure,
|
||||
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
|
||||
timestamp: event.timeStamp.inMicroseconds,
|
||||
pointerDeviceKind: _mapKind(event.kind),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) return;
|
||||
|
||||
// In navigate mode, no drawing at all — pass all events through.
|
||||
if (widget.interactionMode == InteractionMode.navigate) return;
|
||||
|
||||
// In draw mode: stylus and mouse draw, touch passes through for scrolling.
|
||||
if (event.kind == PointerDeviceKind.touch) return;
|
||||
|
||||
_isDrawing = true;
|
||||
_activeTool = widget.tool;
|
||||
|
||||
if (event.kind == PointerDeviceKind.invertedStylus) {
|
||||
_activeTool = PenTool.eraser;
|
||||
}
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// Text tool: record position, handled on pointer up
|
||||
_shapeStart = point;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape tool: record start point
|
||||
_shapeStart = point;
|
||||
_stabilizer?.reset();
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(point);
|
||||
});
|
||||
} else {
|
||||
// Freehand tools (pen, marker, highlighter)
|
||||
_stabilizer?.reset();
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// No preview for text tool
|
||||
return;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape preview: keep only start + current
|
||||
setState(() {
|
||||
if (_currentPoints.length >= 2) {
|
||||
_currentPoints[1] = point;
|
||||
} else {
|
||||
_currentPoints.add(point);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Freehand
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
_isDrawing = false;
|
||||
|
||||
final activeTool = _activeTool ?? widget.tool;
|
||||
|
||||
if (activeTool == PenTool.eraser) {
|
||||
// Nothing to finalize
|
||||
} else if (_isTextTool) {
|
||||
if (_shapeStart != null) {
|
||||
_showTextDialog(_shapeStart!);
|
||||
}
|
||||
} else if (_isShapeTool) {
|
||||
// Shape: finalize with start + end points
|
||||
if (_currentPoints.length >= 2) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
filled: widget.filled,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
} else if (_currentPoints.isNotEmpty) {
|
||||
// Freehand
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: activeTool == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_shapeStart = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showTextDialog(InkPoint position) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Text'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Enter text...'),
|
||||
maxLines: null,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: [position],
|
||||
tool: PenTool.text,
|
||||
color: widget.color.toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
textContent: text,
|
||||
fontSize: widget.strokeWidth * 7,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _eraseAt(InkPoint point) {
|
||||
final eraserRadius = widget.strokeWidth * 3;
|
||||
|
||||
// Collect all (strokeId, replacements) pairs before invoking any callback,
|
||||
// to avoid ConcurrentModificationError when the parent's onErase triggers
|
||||
// a setState that mutates widget.strokes mid-iteration.
|
||||
final toErase = <(String, List<InkStroke>)>[];
|
||||
|
||||
for (final stroke in widget.strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
|
||||
final erasedIndices = <int>{};
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
final p = stroke.points[i];
|
||||
final dx = p.x - point.x;
|
||||
final dy = p.y - point.y;
|
||||
if (dx * dx + dy * dy < eraserRadius * eraserRadius) {
|
||||
erasedIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (erasedIndices.isEmpty) continue;
|
||||
|
||||
toErase.add((stroke.id, _splitStroke(stroke, erasedIndices)));
|
||||
}
|
||||
|
||||
for (final (strokeId, replacements) in toErase) {
|
||||
widget.onErase?.call(strokeId, replacements);
|
||||
}
|
||||
}
|
||||
|
||||
List<InkStroke> _splitStroke(InkStroke stroke, Set<int> erasedIndices) {
|
||||
final segments = <List<InkPoint>>[];
|
||||
List<InkPoint> currentSegment = [];
|
||||
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
if (erasedIndices.contains(i)) {
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
} else {
|
||||
currentSegment.add(stroke.points[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
}
|
||||
|
||||
final replacements = <InkStroke>[];
|
||||
for (final segment in segments) {
|
||||
if (segment.length >= 2) {
|
||||
replacements.add(
|
||||
InkStroke(
|
||||
id: _generateId(),
|
||||
points: segment,
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
Color _getColorForTool(PenTool tool) {
|
||||
switch (tool) {
|
||||
case PenTool.marker:
|
||||
return widget.color.withAlpha(77);
|
||||
case PenTool.highlighter:
|
||||
return const Color(0x80FFFF00);
|
||||
case PenTool.pen:
|
||||
case PenTool.eraser:
|
||||
case PenTool.rectangle:
|
||||
case PenTool.ellipse:
|
||||
case PenTool.line:
|
||||
case PenTool.arrow:
|
||||
case PenTool.text:
|
||||
return widget.color;
|
||||
}
|
||||
}
|
||||
|
||||
String _generateId() {
|
||||
return const Uuid().v4();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerUp,
|
||||
child: CustomPaint(
|
||||
painter: _InkPainter(
|
||||
strokes: widget.strokes,
|
||||
currentPoints: _currentPoints,
|
||||
currentTool: _activeTool ?? widget.tool,
|
||||
currentColor: _getColorForTool(_activeTool ?? widget.tool),
|
||||
currentStrokeWidth:
|
||||
(_activeTool ?? widget.tool) == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
pressureCurve: widget.pressureCurve,
|
||||
filled: widget.filled,
|
||||
viewportBounds: widget.viewportBounds,
|
||||
),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InkPainter extends CustomPainter {
|
||||
final List<InkStroke> strokes;
|
||||
final List<InkPoint> currentPoints;
|
||||
final PenTool currentTool;
|
||||
final Color currentColor;
|
||||
final double currentStrokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final bool filled;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
_InkPainter({
|
||||
required this.strokes,
|
||||
required this.currentPoints,
|
||||
required this.currentTool,
|
||||
required this.currentColor,
|
||||
required this.currentStrokeWidth,
|
||||
required this.pressureCurve,
|
||||
required this.filled,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
bool _strokeInViewport(InkStroke stroke, Rect viewport) {
|
||||
if (stroke.points.isEmpty) return false;
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
return viewport.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
if (viewportBounds != null &&
|
||||
!_strokeInViewport(stroke, viewportBounds!)) {
|
||||
continue;
|
||||
}
|
||||
_drawStroke(
|
||||
canvas,
|
||||
stroke.points,
|
||||
stroke.tool,
|
||||
Color(stroke.color),
|
||||
stroke.strokeWidth,
|
||||
true,
|
||||
stroke.filled,
|
||||
stroke.textContent,
|
||||
stroke.fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPoints.isNotEmpty && currentTool != PenTool.eraser) {
|
||||
_drawStroke(
|
||||
canvas,
|
||||
currentPoints,
|
||||
currentTool,
|
||||
currentColor,
|
||||
currentStrokeWidth,
|
||||
false,
|
||||
filled,
|
||||
null,
|
||||
14.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawStroke(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
bool strokeFilled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
) {
|
||||
if (points.isEmpty) return;
|
||||
|
||||
switch (tool) {
|
||||
case PenTool.pen:
|
||||
case PenTool.marker:
|
||||
case PenTool.highlighter:
|
||||
case PenTool.eraser:
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
break;
|
||||
case PenTool.rectangle:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawRect(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.ellipse:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawOval(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.line:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawLine(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.arrow:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawArrow(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.text:
|
||||
if (textContent != null && textContent.isNotEmpty) {
|
||||
_drawText(canvas, points, textContent, fontSize, color);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _drawFreehand(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
) {
|
||||
final pfPoints = points
|
||||
.map(
|
||||
(p) => pf.Point(
|
||||
p.x,
|
||||
p.y,
|
||||
pressureCurve.apply(p.pressure).clamp(0.0, 1.0),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
|
||||
? 0.0
|
||||
: 0.7;
|
||||
|
||||
final outlinePoints = pf.getStroke(
|
||||
pfPoints,
|
||||
size: strokeWidth,
|
||||
thinning: thinning,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
taperStart: 0.0,
|
||||
taperEnd: 0.0,
|
||||
capStart: true,
|
||||
capEnd: true,
|
||||
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
|
||||
isComplete: isComplete,
|
||||
);
|
||||
|
||||
if (outlinePoints.isEmpty) return;
|
||||
|
||||
final path = Path();
|
||||
path.moveTo(outlinePoints[0].x, outlinePoints[0].y);
|
||||
|
||||
for (int i = 1; i < outlinePoints.length; i++) {
|
||||
path.lineTo(outlinePoints[i].x, outlinePoints[i].y);
|
||||
}
|
||||
path.close();
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
void _drawRect(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
void _drawOval(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawOval(rect, paint);
|
||||
}
|
||||
|
||||
void _drawLine(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawArrow(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final p1 = Offset(points[0].x, points[0].y);
|
||||
final p2 = Offset(points[1].x, points[1].y);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Main line
|
||||
canvas.drawLine(p1, p2, paint);
|
||||
|
||||
// Arrowhead
|
||||
final dx = p2.dx - p1.dx;
|
||||
final dy = p2.dy - p1.dy;
|
||||
final angle = atan2(dy, dx);
|
||||
final arrowLength = strokeWidth * 5;
|
||||
const arrowAngle = pi / 6; // 30 degrees
|
||||
|
||||
final arrowP1 = Offset(
|
||||
p2.dx - arrowLength * cos(angle - arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle - arrowAngle),
|
||||
);
|
||||
final arrowP2 = Offset(
|
||||
p2.dx - arrowLength * cos(angle + arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle + arrowAngle),
|
||||
);
|
||||
|
||||
canvas.drawLine(p2, arrowP1, paint);
|
||||
canvas.drawLine(p2, arrowP2, paint);
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
String text,
|
||||
double fontSize,
|
||||
Color color,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(canvas, Offset(points[0].x, points[0].y));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _InkPainter oldDelegate) {
|
||||
if (strokes.length != oldDelegate.strokes.length) return true;
|
||||
if (currentPoints.length != oldDelegate.currentPoints.length) return true;
|
||||
for (int i = 0; i < strokes.length; i++) {
|
||||
final a = strokes[i], b = oldDelegate.strokes[i];
|
||||
if (a.id != b.id ||
|
||||
a.color != b.color ||
|
||||
a.strokeWidth != b.strokeWidth ||
|
||||
a.tool != b.tool) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return currentTool != oldDelegate.currentTool;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user