365 lines
9.8 KiB
Dart
365 lines
9.8 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'drawing_line.dart';
|
|
import 'paint_preset.dart';
|
|
|
|
/// Represents an undoable action.
|
|
sealed class DrawingAction {
|
|
const DrawingAction();
|
|
}
|
|
|
|
/// Action for adding a line.
|
|
class AddLineAction extends DrawingAction {
|
|
final DrawingLine line;
|
|
const AddLineAction(this.line);
|
|
}
|
|
|
|
/// Action for erasing lines.
|
|
class EraseAction extends DrawingAction {
|
|
final List<DrawingLine> erasedLines;
|
|
const EraseAction(this.erasedLines);
|
|
}
|
|
|
|
/// 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 = [];
|
|
|
|
/// Stack of actions for undo functionality
|
|
final List<DrawingAction> _undoStack = [];
|
|
|
|
/// Stack of actions for redo functionality
|
|
final List<DrawingAction> _redoStack = [];
|
|
|
|
/// The line currently being drawn (null when not drawing)
|
|
DrawingLine? _currentLine;
|
|
|
|
/// Current paint preset being used
|
|
PaintPreset _currentPreset = PaintPreset.blackPen;
|
|
|
|
/// Whether eraser mode is active
|
|
bool _isEraserMode = false;
|
|
|
|
/// Lines erased in the current eraser stroke (for undo as single action)
|
|
final List<DrawingLine> _currentErasedLines = [];
|
|
|
|
/// 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 eraser mode is active
|
|
bool get isEraserMode => _isEraserMode;
|
|
|
|
/// Whether undo is available
|
|
bool get canUndo => _undoStack.isNotEmpty;
|
|
|
|
/// Whether redo is available
|
|
bool get canRedo => _redoStack.isNotEmpty;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Drawing Operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Starts a new line at the given normalized position.
|
|
void startLine(Offset normalizedPoint) {
|
|
if (_isEraserMode) {
|
|
_currentErasedLines.clear();
|
|
_eraseAtPoint(normalizedPoint);
|
|
return;
|
|
}
|
|
|
|
_currentLine = DrawingLine(
|
|
points: [normalizedPoint],
|
|
color: _currentPreset.color,
|
|
strokeWidth: _currentPreset.strokeWidth,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Adds a point to the current line.
|
|
void addPoint(Offset normalizedPoint) {
|
|
if (_isEraserMode) {
|
|
_eraseAtPoint(normalizedPoint);
|
|
return;
|
|
}
|
|
|
|
if (_currentLine == null) return;
|
|
|
|
// Filter points that are too close to reduce memory usage
|
|
if (_currentLine!.isPointTooClose(normalizedPoint)) return;
|
|
|
|
_currentLine = _currentLine!.addPoint(normalizedPoint);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Completes the current line and adds it to the history.
|
|
void endLine() {
|
|
if (_isEraserMode) {
|
|
// If we erased lines in this stroke, record as single action
|
|
if (_currentErasedLines.isNotEmpty) {
|
|
_undoStack.add(EraseAction(List.from(_currentErasedLines)));
|
|
_redoStack.clear();
|
|
_trimHistory();
|
|
_currentErasedLines.clear();
|
|
notifyListeners(); // Update UI to enable undo button
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_currentLine == null) return;
|
|
|
|
// Only add lines with at least 2 points
|
|
if (_currentLine!.points.length >= 2) {
|
|
_lines.add(_currentLine!);
|
|
_undoStack.add(AddLineAction(_currentLine!));
|
|
_redoStack.clear();
|
|
_trimHistory();
|
|
}
|
|
|
|
_currentLine = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Trims history to maxHistorySteps to prevent memory growth.
|
|
void _trimHistory() {
|
|
while (_undoStack.length > maxHistorySteps) {
|
|
_undoStack.removeAt(0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Eraser Operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Eraser hit radius in normalized coordinates
|
|
static const double _eraserRadius = 0.015;
|
|
|
|
/// Checks if a point is near a line and erases it if so.
|
|
void _eraseAtPoint(Offset point) {
|
|
// Find all lines that intersect with the eraser point
|
|
final linesToRemove = <DrawingLine>[];
|
|
|
|
for (final line in _lines) {
|
|
if (_lineIntersectsPoint(
|
|
line, point, _eraserRadius + line.strokeWidth / 2)) {
|
|
linesToRemove.add(line);
|
|
}
|
|
}
|
|
|
|
// Remove intersecting lines and track them for undo
|
|
for (final line in linesToRemove) {
|
|
_lines.remove(line);
|
|
_currentErasedLines.add(line);
|
|
}
|
|
|
|
if (linesToRemove.isNotEmpty) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Checks if a line intersects with a circular point.
|
|
bool _lineIntersectsPoint(DrawingLine line, Offset point, double radius) {
|
|
// Check if any point is within radius
|
|
for (final linePoint in line.points) {
|
|
if ((linePoint - point).distance <= radius) {
|
|
return true;
|
|
}
|
|
}
|
|
// Check line segments
|
|
for (int i = 0; i < line.points.length - 1; i++) {
|
|
if (_pointToSegmentDistance(point, line.points[i], line.points[i + 1]) <=
|
|
radius) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Calculates the distance from a point to a line segment.
|
|
double _pointToSegmentDistance(Offset point, Offset segStart, Offset segEnd) {
|
|
final dx = segEnd.dx - segStart.dx;
|
|
final dy = segEnd.dy - segStart.dy;
|
|
final lengthSquared = dx * dx + dy * dy;
|
|
|
|
if (lengthSquared == 0) {
|
|
// Segment is a point
|
|
return (point - segStart).distance;
|
|
}
|
|
|
|
// Project point onto the line, clamping to segment
|
|
var t = ((point.dx - segStart.dx) * dx + (point.dy - segStart.dy) * dy) /
|
|
lengthSquared;
|
|
t = t.clamp(0.0, 1.0);
|
|
|
|
final projection = Offset(
|
|
segStart.dx + t * dx,
|
|
segStart.dy + t * dy,
|
|
);
|
|
|
|
return (point - projection).distance;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Eraser Mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Toggles eraser mode.
|
|
void toggleEraserMode() {
|
|
_isEraserMode = !_isEraserMode;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Sets eraser mode.
|
|
void setEraserMode(bool enabled) {
|
|
if (_isEraserMode != enabled) {
|
|
_isEraserMode = enabled;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Undo/Redo
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Undoes the last action.
|
|
void undo() {
|
|
if (!canUndo) return;
|
|
|
|
final action = _undoStack.removeLast();
|
|
|
|
switch (action) {
|
|
case AddLineAction(:final line):
|
|
// Remove the line that was added
|
|
_lines.remove(line);
|
|
_redoStack.add(action);
|
|
case EraseAction(:final erasedLines):
|
|
// Restore the lines that were erased
|
|
_lines.addAll(erasedLines);
|
|
_redoStack.add(action);
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Redoes the last undone action.
|
|
void redo() {
|
|
if (!canRedo) return;
|
|
|
|
final action = _redoStack.removeLast();
|
|
|
|
switch (action) {
|
|
case AddLineAction(:final line):
|
|
// Re-add the line
|
|
_lines.add(line);
|
|
_undoStack.add(action);
|
|
case EraseAction(:final erasedLines):
|
|
// Re-erase the lines
|
|
for (final line in erasedLines) {
|
|
_lines.remove(line);
|
|
}
|
|
_undoStack.add(action);
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Clears all lines from the canvas.
|
|
void clear() {
|
|
_lines.clear();
|
|
_undoStack.clear();
|
|
_redoStack.clear();
|
|
_currentLine = null;
|
|
_currentErasedLines.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paint Preset
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Sets the current paint preset and disables eraser mode.
|
|
void setPreset(PaintPreset preset) {
|
|
_currentPreset = preset;
|
|
_isEraserMode = false;
|
|
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();
|
|
_undoStack.clear();
|
|
_redoStack.clear();
|
|
_currentLine = null;
|
|
_currentErasedLines.clear();
|
|
|
|
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();
|
|
_undoStack.clear();
|
|
_redoStack.clear();
|
|
super.dispose();
|
|
}
|
|
}
|