Custom drawing implementation
This commit is contained in:
12
lib/features/sheet_viewer/drawing/drawing.dart
Normal file
12
lib/features/sheet_viewer/drawing/drawing.dart
Normal 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';
|
||||
186
lib/features/sheet_viewer/drawing/drawing_board.dart
Normal file
186
lib/features/sheet_viewer/drawing/drawing_board.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
lib/features/sheet_viewer/drawing/drawing_canvas.dart
Normal file
176
lib/features/sheet_viewer/drawing/drawing_canvas.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/features/sheet_viewer/drawing/drawing_controller.dart
Normal file
189
lib/features/sheet_viewer/drawing/drawing_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
93
lib/features/sheet_viewer/drawing/drawing_line.dart
Normal file
93
lib/features/sheet_viewer/drawing/drawing_line.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
194
lib/features/sheet_viewer/drawing/drawing_toolbar.dart
Normal file
194
lib/features/sheet_viewer/drawing/drawing_toolbar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/features/sheet_viewer/drawing/paint_preset.dart
Normal file
108
lib/features/sheet_viewer/drawing/paint_preset.dart
Normal 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);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
|
||||
import 'package:flutter_drawing_board/paint_contents.dart';
|
||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
@@ -12,7 +10,7 @@ import 'package:sheetless/core/services/api_client.dart';
|
||||
import 'package:sheetless/core/services/storage_service.dart';
|
||||
|
||||
import '../../shared/input/pedal_shortcuts.dart';
|
||||
import 'widgets/paint_mode_layer.dart';
|
||||
import 'drawing/drawing.dart';
|
||||
import 'widgets/pdf_page_display.dart';
|
||||
import 'widgets/touch_navigation_layer.dart';
|
||||
|
||||
@@ -43,20 +41,21 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
int _currentPage = 1;
|
||||
int _totalPages = 1;
|
||||
bool _isPaintMode = false;
|
||||
late DrawingController _drawingController;
|
||||
|
||||
/// Drawing controller for the current left page
|
||||
late DrawingController _leftDrawingController;
|
||||
|
||||
/// Drawing controller for the right page (two-page mode)
|
||||
late DrawingController _rightDrawingController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize drawing controller with default configuration
|
||||
_drawingController = DrawingController(
|
||||
config: DrawConfig(
|
||||
contentType: SimpleLine,
|
||||
strokeWidth: 4.0,
|
||||
color: Colors.black,
|
||||
),
|
||||
maxHistorySteps: 100, // Limit undo/redo history (default: 100)
|
||||
);
|
||||
|
||||
// Initialize drawing controllers
|
||||
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
||||
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
||||
|
||||
FullScreen.addListener(this);
|
||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||
_documentLoaded = _loadPdf();
|
||||
@@ -64,12 +63,38 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_drawingController.dispose();
|
||||
// Save current annotations synchronously before disposing
|
||||
// Note: This is fire-and-forget, but Hive operations are fast enough
|
||||
_saveCurrentAnnotationsSync();
|
||||
|
||||
_leftDrawingController.dispose();
|
||||
_rightDrawingController.dispose();
|
||||
FullScreen.removeListener(this);
|
||||
_document?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Synchronous version that doesn't await - used in dispose
|
||||
void _saveCurrentAnnotationsSync() {
|
||||
// Save left page (always, since paint mode is single-page only)
|
||||
final leftJson = _leftDrawingController.toJsonString();
|
||||
_storageService.writeAnnotations(
|
||||
widget.sheet.uuid,
|
||||
_currentPage,
|
||||
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
|
||||
);
|
||||
|
||||
// Save right page if in two-page mode
|
||||
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
||||
final rightJson = _rightDrawingController.toJsonString();
|
||||
_storageService.writeAnnotations(
|
||||
widget.sheet.uuid,
|
||||
_currentPage + 1,
|
||||
rightJson.isEmpty || rightJson == '[]' ? null : rightJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -89,9 +114,64 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
_totalPages = _document!.pages.length;
|
||||
});
|
||||
|
||||
// Load annotations for current page(s)
|
||||
await _loadAnnotationsForCurrentPages();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotation Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Loads annotations for the current page(s) from storage.
|
||||
Future<void> _loadAnnotationsForCurrentPages() async {
|
||||
// Load left page annotations
|
||||
final leftJson = await _storageService.readAnnotations(
|
||||
widget.sheet.uuid,
|
||||
_currentPage,
|
||||
);
|
||||
if (leftJson != null && leftJson.isNotEmpty) {
|
||||
_leftDrawingController.fromJsonString(leftJson);
|
||||
} else {
|
||||
_leftDrawingController.clear();
|
||||
}
|
||||
|
||||
// Load right page annotations (two-page mode)
|
||||
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
||||
final rightJson = await _storageService.readAnnotations(
|
||||
widget.sheet.uuid,
|
||||
_currentPage + 1,
|
||||
);
|
||||
if (rightJson != null && rightJson.isNotEmpty) {
|
||||
_rightDrawingController.fromJsonString(rightJson);
|
||||
} else {
|
||||
_rightDrawingController.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the current page(s) annotations to storage.
|
||||
Future<void> _saveCurrentAnnotations() async {
|
||||
// Save left page
|
||||
final leftJson = _leftDrawingController.toJsonString();
|
||||
await _storageService.writeAnnotations(
|
||||
widget.sheet.uuid,
|
||||
_currentPage,
|
||||
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
|
||||
);
|
||||
|
||||
// Save right page (two-page mode)
|
||||
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
||||
final rightJson = _rightDrawingController.toJsonString();
|
||||
await _storageService.writeAnnotations(
|
||||
widget.sheet.uuid,
|
||||
_currentPage + 1,
|
||||
rightJson.isEmpty || rightJson == '[]' ? null : rightJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -105,10 +185,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
if (_isPaintMode) {
|
||||
_showSnackBar('Cannot enter fullscreen while in paint mode');
|
||||
return;
|
||||
}
|
||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
||||
}
|
||||
|
||||
@@ -116,10 +192,19 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
// Navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _turnPage(int delta) {
|
||||
setState(() {
|
||||
_currentPage = (_currentPage + delta).clamp(1, _totalPages);
|
||||
});
|
||||
Future<void> _turnPage(int delta) async {
|
||||
// Save current annotations before turning
|
||||
await _saveCurrentAnnotations();
|
||||
|
||||
// Calculate new page
|
||||
final newPage = (_currentPage + delta).clamp(1, _totalPages);
|
||||
|
||||
// Load annotations for new page(s) BEFORE updating state
|
||||
_currentPage = newPage;
|
||||
await _loadAnnotationsForCurrentPages();
|
||||
|
||||
// Now update UI
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,6 +217,11 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPaintMode) {
|
||||
// Exiting paint mode - save annotations
|
||||
_saveCurrentAnnotations();
|
||||
}
|
||||
|
||||
setState(() => _isPaintMode = !_isPaintMode);
|
||||
}
|
||||
|
||||
@@ -145,6 +235,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
widget.config.twoPageMode = !widget.config.twoPageMode;
|
||||
_storageService.writeConfig(widget.config);
|
||||
});
|
||||
|
||||
// Reload annotations for new mode
|
||||
_loadAnnotationsForCurrentPages();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -156,7 +249,12 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
return PedalShortcuts(
|
||||
onPageForward: () => _turnPage(1),
|
||||
onPageBackward: () => _turnPage(-1),
|
||||
child: Scaffold(appBar: _buildAppBar(), body: _buildBody()),
|
||||
child: Scaffold(
|
||||
appBar: _buildAppBar(),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: _isPaintMode ? _buildDrawingToolbar() : null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,23 +271,21 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
icon: Icon(
|
||||
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
),
|
||||
tooltip: widget.config.fullscreen
|
||||
? 'Exit Fullscreen'
|
||||
: 'Enter Fullscreen',
|
||||
tooltip:
|
||||
widget.config.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
||||
tooltip: 'Toggle Paint Mode',
|
||||
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
|
||||
onPressed: _togglePaintMode,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
||||
),
|
||||
tooltip: widget.config.twoPageMode
|
||||
? 'Single Page Mode'
|
||||
: 'Two Page Mode',
|
||||
tooltip:
|
||||
widget.config.twoPageMode ? 'Single Page Mode' : 'Two Page Mode',
|
||||
onPressed: _toggleTwoPageMode,
|
||||
),
|
||||
],
|
||||
@@ -220,30 +316,37 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
numPages: _totalPages,
|
||||
currentPageNumber: _currentPage,
|
||||
config: widget.config,
|
||||
leftDrawingController: _leftDrawingController,
|
||||
rightDrawingController:
|
||||
widget.config.twoPageMode ? _rightDrawingController : null,
|
||||
drawingEnabled: _isPaintMode,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Show touch navigation when not in paint mode
|
||||
Visibility(
|
||||
visible: !_isPaintMode,
|
||||
child: TouchNavigationLayer(
|
||||
pageDisplay: pageDisplay,
|
||||
config: widget.config,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onExit: () => Navigator.pop(context),
|
||||
onPageTurn: _turnPage,
|
||||
),
|
||||
// When in paint mode, show the page display directly (DrawingBoard handles zoom/pan)
|
||||
if (_isPaintMode) {
|
||||
return pageDisplay;
|
||||
}
|
||||
|
||||
// When not in paint mode, wrap with touch navigation
|
||||
return TouchNavigationLayer(
|
||||
pageDisplay: pageDisplay,
|
||||
config: widget.config,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onExit: () => Navigator.pop(context),
|
||||
onPageTurn: _turnPage,
|
||||
child: pageDisplay,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrawingToolbar() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: DrawingToolbar(
|
||||
controller: _leftDrawingController,
|
||||
onClose: _togglePaintMode,
|
||||
),
|
||||
// Show paint mode layer when active
|
||||
Visibility(
|
||||
visible: _isPaintMode,
|
||||
child: PaintModeLayer(
|
||||
pageDisplay: pageDisplay,
|
||||
drawingController: _drawingController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,9 +356,10 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -264,7 +368,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
||||
|
||||
void _showSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: Duration(seconds: 2)),
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
|
||||
|
||||
import 'pdf_page_display.dart';
|
||||
|
||||
/// Drawing overlay for annotating PDF pages.
|
||||
///
|
||||
/// Uses flutter_drawing_board to provide a paint canvas over the PDF.
|
||||
/// Only working in single-page mode.
|
||||
class PaintModeLayer extends StatelessWidget {
|
||||
final PdfPageDisplay pageDisplay;
|
||||
final DrawingController drawingController;
|
||||
|
||||
const PaintModeLayer({
|
||||
super.key,
|
||||
required this.pageDisplay,
|
||||
required this.drawingController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final (pageSize, _) = pageDisplay.calculateScaledPageSizes(maxSize);
|
||||
|
||||
return DrawingBoard(
|
||||
controller: drawingController,
|
||||
background: SizedBox(
|
||||
width: pageSize.width,
|
||||
height: pageSize.height,
|
||||
child: pageDisplay,
|
||||
),
|
||||
boardConstrained: true,
|
||||
minScale: 1,
|
||||
maxScale: 3,
|
||||
alignment: Alignment.topRight,
|
||||
boardBoundaryMargin: EdgeInsets.zero,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,33 @@ import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import '../../../core/models/config.dart';
|
||||
import '../drawing/drawing.dart';
|
||||
|
||||
/// Displays PDF pages with optional two-page mode.
|
||||
/// Displays PDF pages with optional two-page mode and drawing overlay.
|
||||
class PdfPageDisplay extends StatelessWidget {
|
||||
final PdfDocument document;
|
||||
final int numPages;
|
||||
final int currentPageNumber;
|
||||
final Config config;
|
||||
|
||||
/// Controller for the left/main page drawing
|
||||
final DrawingController? leftDrawingController;
|
||||
|
||||
/// Controller for the right page drawing (two-page mode only)
|
||||
final DrawingController? rightDrawingController;
|
||||
|
||||
/// Whether drawing is enabled
|
||||
final bool drawingEnabled;
|
||||
|
||||
const PdfPageDisplay({
|
||||
super.key,
|
||||
required this.document,
|
||||
required this.numPages,
|
||||
required this.currentPageNumber,
|
||||
required this.config,
|
||||
this.leftDrawingController,
|
||||
this.rightDrawingController,
|
||||
this.drawingEnabled = false,
|
||||
});
|
||||
|
||||
/// Whether two-page mode is active and we have enough pages.
|
||||
@@ -25,55 +38,102 @@ class PdfPageDisplay extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [_buildLeftPage(), if (_showTwoPages) _buildRightPage()],
|
||||
);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final (leftSize, rightSize) = calculateScaledPageSizes(maxSize);
|
||||
|
||||
Widget _buildLeftPage() {
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
PdfPageView(
|
||||
key: ValueKey(currentPageNumber),
|
||||
document: document,
|
||||
if (_showTwoPages) {
|
||||
// Two-page mode: pages touch each other and are centered together
|
||||
return Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildPage(
|
||||
pageNumber: currentPageNumber,
|
||||
pageSize: leftSize,
|
||||
controller: leftDrawingController,
|
||||
),
|
||||
// Only show right page if there is one
|
||||
if (currentPageNumber < numPages)
|
||||
_buildPage(
|
||||
pageNumber: currentPageNumber + 1,
|
||||
pageSize: rightSize!,
|
||||
controller: rightDrawingController,
|
||||
)
|
||||
else
|
||||
// Empty space to keep left page position consistent on last page
|
||||
SizedBox(width: rightSize!.width, height: rightSize.height),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Single page mode
|
||||
return Center(
|
||||
child: _buildPage(
|
||||
pageNumber: currentPageNumber,
|
||||
maximumDpi: 300,
|
||||
alignment: _showTwoPages ? Alignment.centerRight : Alignment.center,
|
||||
pageSize: leftSize,
|
||||
controller: leftDrawingController,
|
||||
),
|
||||
_buildPageIndicator(currentPageNumber),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRightPage() {
|
||||
final rightPageNumber = currentPageNumber + 1;
|
||||
|
||||
return Expanded(
|
||||
Widget _buildPage({
|
||||
required int pageNumber,
|
||||
required Size pageSize,
|
||||
required DrawingController? controller,
|
||||
}) {
|
||||
final pdfPage = SizedBox(
|
||||
width: pageSize.width,
|
||||
height: pageSize.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
PdfPageView(
|
||||
key: ValueKey(rightPageNumber),
|
||||
key: ValueKey(pageNumber),
|
||||
document: document,
|
||||
pageNumber: rightPageNumber,
|
||||
pageNumber: pageNumber,
|
||||
maximumDpi: 300,
|
||||
alignment: Alignment.centerLeft,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
_buildPageIndicator(rightPageNumber),
|
||||
_buildPageIndicator(pageNumber, pageSize),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// If no controller, just show the PDF
|
||||
if (controller == null) {
|
||||
return pdfPage;
|
||||
}
|
||||
|
||||
// Wrap with DrawingBoard
|
||||
return DrawingBoard(
|
||||
boardSize: pageSize,
|
||||
controller: controller,
|
||||
drawingEnabled: drawingEnabled,
|
||||
minScale: 1.0,
|
||||
maxScale: 3.0,
|
||||
alignment: Alignment.center,
|
||||
child: pdfPage,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageIndicator(int pageNumber) {
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
padding: const EdgeInsets.only(bottom: 5),
|
||||
child: Text('$pageNumber / $numPages'),
|
||||
Widget _buildPageIndicator(int pageNumber, Size pageSize) {
|
||||
return Positioned(
|
||||
bottom: 5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$pageNumber / $numPages',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../../../core/models/config.dart';
|
||||
import 'pdf_page_display.dart';
|
||||
|
||||
/// Callback for page turn events.
|
||||
typedef PageTurnCallback = void Function(int delta);
|
||||
typedef PageTurnCallback = dynamic Function(int delta);
|
||||
|
||||
/// Gesture layer for touch-based navigation over PDF pages.
|
||||
///
|
||||
@@ -14,6 +14,7 @@ typedef PageTurnCallback = void Function(int delta);
|
||||
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
|
||||
class TouchNavigationLayer extends StatelessWidget {
|
||||
final PdfPageDisplay pageDisplay;
|
||||
final Widget child;
|
||||
final Config config;
|
||||
final VoidCallback onToggleFullscreen;
|
||||
final VoidCallback onExit;
|
||||
@@ -22,6 +23,7 @@ class TouchNavigationLayer extends StatelessWidget {
|
||||
const TouchNavigationLayer({
|
||||
super.key,
|
||||
required this.pageDisplay,
|
||||
required this.child,
|
||||
required this.config,
|
||||
required this.onToggleFullscreen,
|
||||
required this.onExit,
|
||||
@@ -33,7 +35,7 @@ class TouchNavigationLayer extends StatelessWidget {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (details) => _handleTap(context, details),
|
||||
child: pageDisplay,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user