Custom drawing implementation

This commit is contained in:
2026-02-05 17:47:03 +01:00
parent e1d72de718
commit d4d6e41a9d
14 changed files with 1291 additions and 147 deletions

View File

@@ -0,0 +1,12 @@
/// Custom drawing library for PDF annotations.
///
/// Provides scalable drawing with normalized coordinates (0-1 range)
/// that work correctly when the canvas size changes.
library;
export 'drawing_board.dart';
export 'drawing_canvas.dart';
export 'drawing_controller.dart';
export 'drawing_line.dart';
export 'drawing_toolbar.dart';
export 'paint_preset.dart';

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'drawing_canvas.dart';
import 'drawing_controller.dart';
/// A drawing board that overlays a child widget with a drawing canvas.
///
/// Supports:
/// - Drawing with normalized coordinates that scale correctly
/// - Zooming and panning via InteractiveViewer
/// - Toggle between view-only and drawing modes
class DrawingBoard extends StatefulWidget {
/// The widget to display behind the drawing (e.g., PDF page)
final Widget child;
/// Size of the drawing area (should match child size)
final Size boardSize;
/// Controller for managing drawing state
final DrawingController controller;
/// Whether drawing is enabled (false = view only)
final bool drawingEnabled;
/// Minimum zoom scale
final double minScale;
/// Maximum zoom scale
final double maxScale;
/// Alignment of the board within available space
final Alignment alignment;
const DrawingBoard({
super.key,
required this.child,
required this.boardSize,
required this.controller,
this.drawingEnabled = true,
this.minScale = 1.0,
this.maxScale = 3.0,
this.alignment = Alignment.topCenter,
});
@override
State<DrawingBoard> createState() => _DrawingBoardState();
}
class _DrawingBoardState extends State<DrawingBoard> {
final TransformationController _transformationController =
TransformationController();
/// Tracks whether we're currently in a drawing gesture
bool _isDrawing = false;
/// Tracks the number of active pointers for gesture disambiguation
int _pointerCount = 0;
@override
void dispose() {
_transformationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The content that will be transformed (zoomed/panned)
final content = SizedBox(
width: widget.boardSize.width,
height: widget.boardSize.height,
child: Stack(
children: [
// Background child (e.g., PDF)
Positioned.fill(child: widget.child),
// Drawing overlay
Positioned.fill(
child: widget.drawingEnabled
? _buildDrawingLayer()
: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
),
],
),
);
if (!widget.drawingEnabled) {
// View-only mode: just show the content (no zoom/pan here,
// let parent handle navigation)
return Align(
alignment: widget.alignment,
child: content,
);
}
// Drawing mode: wrap with InteractiveViewer for zoom/pan
return Align(
alignment: widget.alignment,
child: InteractiveViewer(
transformationController: _transformationController,
minScale: widget.minScale,
maxScale: widget.maxScale,
boundaryMargin: EdgeInsets.zero,
constrained: true,
panEnabled: !_isDrawing,
scaleEnabled: !_isDrawing,
child: content,
),
);
}
/// Builds the drawing layer with gesture handling.
///
/// Uses Listener for drawing and distinguishes between:
/// - Single finger: Draw
/// - Two+ fingers: Pan/Zoom (handled by InteractiveViewer)
Widget _buildDrawingLayer() {
return Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
behavior: HitTestBehavior.translucent,
child: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
);
}
void _onPointerDown(PointerDownEvent event) {
_pointerCount++;
// Only start drawing with single finger touch
if (_pointerCount == 1) {
_isDrawing = true;
final normalized = _toNormalized(event.localPosition);
widget.controller.startLine(normalized);
setState(() {}); // Update to disable pan/scale
} else {
// Multiple fingers: cancel drawing, enable pan/zoom
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
}
void _onPointerMove(PointerMoveEvent event) {
if (_isDrawing && _pointerCount == 1) {
final normalized = _toNormalized(event.localPosition);
widget.controller.addPoint(normalized);
}
}
void _onPointerUp(PointerUpEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {}); // Re-enable pan/scale
}
}
void _onPointerCancel(PointerCancelEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
/// Converts local position to normalized coordinates (0-1).
Offset _toNormalized(Offset localPosition) {
return Offset(
(localPosition.dx / widget.boardSize.width).clamp(0.0, 1.0),
(localPosition.dy / widget.boardSize.height).clamp(0.0, 1.0),
);
}
}

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'drawing_controller.dart';
import 'drawing_line.dart';
/// Custom painter that renders drawing lines on a canvas.
///
/// Converts normalized coordinates (0-1) to actual canvas coordinates
/// based on the provided canvas size.
class DrawingPainter extends CustomPainter {
final List<DrawingLine> lines;
final DrawingLine? currentLine;
final Size canvasSize;
DrawingPainter({
required this.lines,
required this.currentLine,
required this.canvasSize,
});
@override
void paint(Canvas canvas, Size size) {
// Draw all completed lines
for (final line in lines) {
_drawLine(canvas, line);
}
// Draw the current line being drawn
if (currentLine != null) {
_drawLine(canvas, currentLine!);
}
}
void _drawLine(Canvas canvas, DrawingLine line) {
if (line.points.length < 2) return;
final paint = Paint()
..color = line.color
..strokeWidth = line.strokeWidth * canvasSize.width
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..isAntiAlias = true;
// Create path from normalized points
final path = Path();
final firstPoint = _toCanvasPoint(line.points.first);
path.moveTo(firstPoint.dx, firstPoint.dy);
// Use quadratic bezier curves for smooth lines
if (line.points.length == 2) {
final endPoint = _toCanvasPoint(line.points.last);
path.lineTo(endPoint.dx, endPoint.dy);
} else {
for (int i = 1; i < line.points.length - 1; i++) {
final p0 = _toCanvasPoint(line.points[i]);
final p1 = _toCanvasPoint(line.points[i + 1]);
final midPoint = Offset(
(p0.dx + p1.dx) / 2,
(p0.dy + p1.dy) / 2,
);
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
}
// Draw to the last point
final lastPoint = _toCanvasPoint(line.points.last);
path.lineTo(lastPoint.dx, lastPoint.dy);
}
canvas.drawPath(path, paint);
}
/// Converts a normalized point (0-1) to canvas coordinates.
Offset _toCanvasPoint(Offset normalizedPoint) {
return Offset(
normalizedPoint.dx * canvasSize.width,
normalizedPoint.dy * canvasSize.height,
);
}
@override
bool shouldRepaint(covariant DrawingPainter oldDelegate) {
return lines != oldDelegate.lines ||
currentLine != oldDelegate.currentLine ||
canvasSize != oldDelegate.canvasSize;
}
}
/// A widget that displays drawing lines on a transparent canvas.
///
/// This widget only shows the drawings, it doesn't handle input.
/// Use [DrawingCanvas] or [DrawingBoard] for input handling.
class DrawingOverlay extends StatelessWidget {
final DrawingController controller;
final Size canvasSize;
const DrawingOverlay({
super.key,
required this.controller,
required this.canvasSize,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return CustomPaint(
size: canvasSize,
painter: DrawingPainter(
lines: controller.lines,
currentLine: controller.currentLine,
canvasSize: canvasSize,
),
);
},
);
}
}
/// A widget that handles drawing input and renders lines.
///
/// Converts touch/pointer events to normalized coordinates and
/// passes them to the [DrawingController].
class DrawingCanvas extends StatelessWidget {
final DrawingController controller;
final Size canvasSize;
final bool enabled;
const DrawingCanvas({
super.key,
required this.controller,
required this.canvasSize,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: enabled ? _onPointerDown : null,
onPointerMove: enabled ? _onPointerMove : null,
onPointerUp: enabled ? _onPointerUp : null,
onPointerCancel: enabled ? _onPointerCancel : null,
behavior: HitTestBehavior.opaque,
child: DrawingOverlay(
controller: controller,
canvasSize: canvasSize,
),
);
}
void _onPointerDown(PointerDownEvent event) {
final normalized = _toNormalized(event.localPosition);
controller.startLine(normalized);
}
void _onPointerMove(PointerMoveEvent event) {
final normalized = _toNormalized(event.localPosition);
controller.addPoint(normalized);
}
void _onPointerUp(PointerUpEvent event) {
controller.endLine();
}
void _onPointerCancel(PointerCancelEvent event) {
controller.endLine();
}
/// Converts canvas coordinates to normalized coordinates (0-1).
Offset _toNormalized(Offset canvasPoint) {
return Offset(
(canvasPoint.dx / canvasSize.width).clamp(0.0, 1.0),
(canvasPoint.dy / canvasSize.height).clamp(0.0, 1.0),
);
}
}

View File

@@ -0,0 +1,189 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'drawing_line.dart';
import 'paint_preset.dart';
/// Controller for managing drawing state with undo/redo support.
///
/// Manages a stack of [DrawingLine] objects and provides methods for
/// drawing, undoing, redoing, and serializing the drawing state.
class DrawingController extends ChangeNotifier {
/// All completed lines in the drawing
final List<DrawingLine> _lines = [];
/// Lines that have been undone (for redo functionality)
final List<DrawingLine> _undoneLines = [];
/// The line currently being drawn (null when not drawing)
DrawingLine? _currentLine;
/// Current paint preset being used
PaintPreset _currentPreset = PaintPreset.blackPen;
/// Maximum number of history steps to keep
final int maxHistorySteps;
DrawingController({this.maxHistorySteps = 50});
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
/// All completed lines (read-only)
List<DrawingLine> get lines => List.unmodifiable(_lines);
/// The line currently being drawn
DrawingLine? get currentLine => _currentLine;
/// Current paint preset
PaintPreset get currentPreset => _currentPreset;
/// Whether undo is available
bool get canUndo => _lines.isNotEmpty;
/// Whether redo is available
bool get canRedo => _undoneLines.isNotEmpty;
// ---------------------------------------------------------------------------
// Drawing Operations
// ---------------------------------------------------------------------------
/// Starts a new line at the given normalized position.
void startLine(Offset normalizedPoint) {
_currentLine = DrawingLine(
points: [normalizedPoint],
color: _currentPreset.color,
strokeWidth: _currentPreset.strokeWidth,
);
notifyListeners();
}
/// Adds a point to the current line.
void addPoint(Offset normalizedPoint) {
if (_currentLine == null) return;
_currentLine = _currentLine!.addPoint(normalizedPoint);
notifyListeners();
}
/// Completes the current line and adds it to the history.
void endLine() {
if (_currentLine == null) return;
// Only add lines with at least 2 points
if (_currentLine!.points.length >= 2) {
_lines.add(_currentLine!);
// Clear redo stack when new action is performed
_undoneLines.clear();
_trimHistory();
}
_currentLine = null;
notifyListeners();
}
/// Trims history to maxHistorySteps to prevent memory growth.
void _trimHistory() {
while (_lines.length > maxHistorySteps) {
_lines.removeAt(0);
}
}
// ---------------------------------------------------------------------------
// Undo/Redo
// ---------------------------------------------------------------------------
/// Undoes the last line drawn.
void undo() {
if (!canUndo) return;
final line = _lines.removeLast();
_undoneLines.add(line);
notifyListeners();
}
/// Redoes the last undone line.
void redo() {
if (!canRedo) return;
final line = _undoneLines.removeLast();
_lines.add(line);
notifyListeners();
}
/// Clears all lines from the canvas.
void clear() {
_lines.clear();
_undoneLines.clear();
_currentLine = null;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Paint Preset
// ---------------------------------------------------------------------------
/// Sets the current paint preset.
void setPreset(PaintPreset preset) {
_currentPreset = preset;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/// Exports all lines to a JSON-serializable list.
List<Map<String, dynamic>> toJsonList() {
return _lines.map((line) => line.toJson()).toList();
}
/// Exports all lines to a JSON string.
String toJsonString() {
return jsonEncode(toJsonList());
}
/// Imports lines from a JSON-serializable list.
void fromJsonList(List<Map<String, dynamic>> jsonList) {
_lines.clear();
_undoneLines.clear();
_currentLine = null;
for (final json in jsonList) {
_lines.add(DrawingLine.fromJson(json));
}
notifyListeners();
}
/// Imports lines from a JSON string.
void fromJsonString(String jsonString) {
if (jsonString.isEmpty || jsonString == '[]') {
clear();
return;
}
final decoded = jsonDecode(jsonString) as List;
if (decoded.isEmpty) {
clear();
return;
}
fromJsonList(decoded.cast<Map<String, dynamic>>());
}
/// Adds existing lines without clearing (for merging annotations).
void addLines(List<DrawingLine> newLines) {
_lines.addAll(newLines);
_trimHistory();
notifyListeners();
}
@override
void dispose() {
_lines.clear();
_undoneLines.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:ui';
/// Represents a single stroke/line drawn on the canvas.
///
/// Points are stored in normalized coordinates (0.0 to 1.0) where:
/// - (0, 0) is the top-left corner of the drawing area
/// - (1, 1) is the bottom-right corner of the drawing area
///
/// This allows drawings to scale correctly when the canvas size changes.
class DrawingLine {
/// Points in normalized coordinates (0.0 to 1.0)
final List<Offset> points;
/// Color of the line (stored as ARGB integer for JSON serialization)
final Color color;
/// Stroke width in normalized units (relative to canvas width)
/// A value of 0.01 means the stroke is 1% of the canvas width
final double strokeWidth;
const DrawingLine({
required this.points,
required this.color,
required this.strokeWidth,
});
/// Creates a DrawingLine from JSON data.
factory DrawingLine.fromJson(Map<String, dynamic> json) {
final pointsList = (json['points'] as List)
.map((p) => Offset(
(p['x'] as num).toDouble(),
(p['y'] as num).toDouble(),
))
.toList();
return DrawingLine(
points: pointsList,
color: Color(json['color'] as int),
strokeWidth: (json['strokeWidth'] as num).toDouble(),
);
}
/// Converts this line to a JSON-serializable map.
Map<String, dynamic> toJson() {
return {
'points': points.map((p) => {'x': p.dx, 'y': p.dy}).toList(),
'color': color.toARGB32(),
'strokeWidth': strokeWidth,
};
}
/// Creates a copy of this line with an additional point.
DrawingLine addPoint(Offset normalizedPoint) {
return DrawingLine(
points: [...points, normalizedPoint],
color: color,
strokeWidth: strokeWidth,
);
}
/// Creates a copy with updated points.
DrawingLine copyWith({
List<Offset>? points,
Color? color,
double? strokeWidth,
}) {
return DrawingLine(
points: points ?? this.points,
color: color ?? this.color,
strokeWidth: strokeWidth ?? this.strokeWidth,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DrawingLine) return false;
if (points.length != other.points.length) return false;
if (color != other.color) return false;
if (strokeWidth != other.strokeWidth) return false;
for (int i = 0; i < points.length; i++) {
if (points[i] != other.points[i]) return false;
}
return true;
}
@override
int get hashCode => Object.hash(
Object.hashAll(points),
color,
strokeWidth,
);
}

View File

@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'drawing_controller.dart';
import 'paint_preset.dart';
/// A floating toolbar for drawing controls.
///
/// Provides quick access to:
/// - Paint presets (pens and markers)
/// - Undo/Redo buttons
class DrawingToolbar extends StatelessWidget {
final DrawingController controller;
final VoidCallback? onClose;
const DrawingToolbar({
super.key,
required this.controller,
this.onClose,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Paint presets
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
context,
preset,
isSelected: controller.currentPreset == preset,
)),
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
// Undo button
_buildActionButton(
context,
icon: Icons.undo,
onPressed: controller.canUndo ? controller.undo : null,
tooltip: 'Undo',
),
// Redo button
_buildActionButton(
context,
icon: Icons.redo,
onPressed: controller.canRedo ? controller.redo : null,
tooltip: 'Redo',
),
if (onClose != null) ...[
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
_buildActionButton(
context,
icon: Icons.close,
onPressed: onClose,
tooltip: 'Exit Drawing Mode',
),
],
],
),
);
},
);
}
Widget _buildPresetButton(
BuildContext context,
PaintPreset preset, {
required bool isSelected,
}) {
final isMarker = preset.strokeWidth > 0.01;
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: preset.name,
child: InkWell(
onTap: () => controller.setPreset(preset),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Center(
child: Container(
width: isMarker ? 24 : 18,
height: isMarker ? 12 : 18,
decoration: BoxDecoration(
color: preset.color,
borderRadius: isMarker
? BorderRadius.circular(2)
: BorderRadius.circular(9),
border: preset.color.a < 1
? Border.all(color: Colors.grey.shade400, width: 0.5)
: null,
),
),
),
),
),
);
}
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required VoidCallback? onPressed,
required String tooltip,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
icon,
size: 20,
color: onPressed != null
? colorScheme.onSurface
: colorScheme.onSurface.withValues(alpha: 0.3),
),
),
),
);
}
Widget _buildDivider(BuildContext context) {
return Container(
width: 1,
height: 24,
color: Theme.of(context).dividerColor,
);
}
}
/// A compact floating action button to toggle paint mode.
class DrawingModeButton extends StatelessWidget {
final bool isActive;
final VoidCallback onPressed;
const DrawingModeButton({
super.key,
required this.isActive,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton.small(
onPressed: onPressed,
backgroundColor: isActive
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
child: Icon(
isActive ? Icons.brush : Icons.brush_outlined,
color: isActive
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
/// Predefined paint configurations for common annotation styles.
///
/// Each preset defines a color and stroke width for drawing.
/// Stroke width is normalized (relative to canvas width).
class PaintPreset {
/// Display name for the preset
final String name;
/// Color of the paint (including opacity)
final Color color;
/// Stroke width in normalized units (relative to canvas width)
/// A value of 0.005 means the stroke is 0.5% of the canvas width
final double strokeWidth;
/// Icon to display for this preset
final IconData icon;
const PaintPreset({
required this.name,
required this.color,
required this.strokeWidth,
required this.icon,
});
// ---------------------------------------------------------------------------
// Default Presets
// ---------------------------------------------------------------------------
/// Black pen for writing/notes
static const blackPen = PaintPreset(
name: 'Black Pen',
color: Colors.black,
strokeWidth: 0.003, // Thin line for writing
icon: Icons.edit,
);
/// Red pen for corrections/markings
static const redPen = PaintPreset(
name: 'Red Pen',
color: Colors.red,
strokeWidth: 0.003,
icon: Icons.edit,
);
/// Blue pen for annotations
static const bluePen = PaintPreset(
name: 'Blue Pen',
color: Colors.blue,
strokeWidth: 0.003,
icon: Icons.edit,
);
/// Yellow highlighter (semi-transparent, thicker)
static const yellowMarker = PaintPreset(
name: 'Yellow Marker',
color: Color(0x80FFEB3B), // Yellow with 50% opacity
strokeWidth: 0.015, // Thicker for highlighting
icon: Icons.highlight,
);
/// Green highlighter
static const greenMarker = PaintPreset(
name: 'Green Marker',
color: Color(0x804CAF50), // Green with 50% opacity
strokeWidth: 0.015,
icon: Icons.highlight,
);
/// Pink highlighter
static const pinkMarker = PaintPreset(
name: 'Pink Marker',
color: Color(0x80E91E63), // Pink with 50% opacity
strokeWidth: 0.015,
icon: Icons.highlight,
);
/// All available default presets
static const List<PaintPreset> defaults = [
blackPen,
redPen,
bluePen,
yellowMarker,
greenMarker,
pinkMarker,
];
/// Quick access presets (shown in main toolbar)
static const List<PaintPreset> quickAccess = [
blackPen,
redPen,
yellowMarker,
];
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PaintPreset &&
other.name == name &&
other.color == color &&
other.strokeWidth == strokeWidth;
}
@override
int get hashCode => Object.hash(name, color, strokeWidth);
}