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 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 _lines = []; /// Stack of actions for undo functionality final List _undoStack = []; /// Stack of actions for redo functionality final List _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 _currentErasedLines = []; /// Maximum number of history steps to keep final int maxHistorySteps; DrawingController({this.maxHistorySteps = 50}); // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- /// All completed lines (read-only) List 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 = []; 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> 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> 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>()); } /// Adds existing lines without clearing (for merging annotations). void addLines(List newLines) { _lines.addAll(newLines); _trimHistory(); notifyListeners(); } @override void dispose() { _lines.clear(); _undoStack.clear(); _redoStack.clear(); super.dispose(); } }