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 strokes; final void Function(InkStroke stroke)? onStrokeComplete; final void Function(String strokeId, List 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 createState() => _InkCanvasState(); } class _InkCanvasState extends State { final List _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)>[]; for (final stroke in widget.strokes) { if (stroke.tool == PenTool.eraser) continue; final erasedIndices = {}; 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 _splitStroke(InkStroke stroke, Set erasedIndices) { final segments = >[]; List 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 = []; 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 strokes; final List 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 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 points, PenTool tool, Color color, double strokeWidth, bool isComplete, ) { final pfPoints = points .map( (p) => pf.PointVector( 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, options: pf.StrokeOptions( size: strokeWidth, thinning: thinning, smoothing: 0.5, streamline: 0.5, // 2.x defaults: no taper + capped ends (was taperStart/End:0 + // capStart/End:true in 1.0.4). simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter, isComplete: isComplete, ), ); if (outlinePoints.isEmpty) return; final path = Path(); path.moveTo(outlinePoints[0].dx, outlinePoints[0].dy); for (int i = 1; i < outlinePoints.length; i++) { path.lineTo(outlinePoints[i].dx, outlinePoints[i].dy); } path.close(); final paint = Paint() ..color = color ..style = PaintingStyle.fill ..isAntiAlias = true; canvas.drawPath(path, paint); } void _drawRect( Canvas canvas, List 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 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 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 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 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; } }