Custom drawing implementation
This commit is contained in:
@@ -9,12 +9,14 @@ enum SecureStorageKey { url, jwt, email }
|
|||||||
/// Service for managing local storage operations.
|
/// Service for managing local storage operations.
|
||||||
///
|
///
|
||||||
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
||||||
/// and [Hive] for general app data (config, sheet access times, change queue).
|
/// and [Hive] for general app data (config, sheet access times, change queue,
|
||||||
|
/// and PDF annotations).
|
||||||
class StorageService {
|
class StorageService {
|
||||||
// Hive box names
|
// Hive box names
|
||||||
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
|
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
|
||||||
static const String _configBox = 'config';
|
static const String _configBox = 'config';
|
||||||
static const String _changeQueueBox = 'changeQueue';
|
static const String _changeQueueBox = 'changeQueue';
|
||||||
|
static const String _annotationsBox = 'annotations';
|
||||||
|
|
||||||
late final FlutterSecureStorage _secureStorage;
|
late final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
@@ -75,7 +77,8 @@ class StorageService {
|
|||||||
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
||||||
final box = await Hive.openBox(_sheetAccessTimesBox);
|
final box = await Hive.openBox(_sheetAccessTimesBox);
|
||||||
return box.toMap().map(
|
return box.toMap().map(
|
||||||
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
|
(key, value) =>
|
||||||
|
MapEntry(key as String, DateTime.parse(value as String)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,4 +119,75 @@ class StorageService {
|
|||||||
await box.deleteAt(0);
|
await box.deleteAt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Annotations (PDF Drawing Persistence)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generates a storage key for a specific page's annotations.
|
||||||
|
String _annotationKey(String sheetUuid, int pageNumber) {
|
||||||
|
return '${sheetUuid}_page_$pageNumber';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads annotations for a specific sheet page.
|
||||||
|
///
|
||||||
|
/// Returns the JSON string of annotations, or null if none exist.
|
||||||
|
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
return box.get(_annotationKey(sheetUuid, pageNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes annotations for a specific sheet page.
|
||||||
|
///
|
||||||
|
/// Pass null or empty string to delete annotations for that page.
|
||||||
|
Future<void> writeAnnotations(
|
||||||
|
String sheetUuid,
|
||||||
|
int pageNumber,
|
||||||
|
String? annotationsJson,
|
||||||
|
) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final key = _annotationKey(sheetUuid, pageNumber);
|
||||||
|
|
||||||
|
if (annotationsJson == null || annotationsJson.isEmpty) {
|
||||||
|
await box.delete(key);
|
||||||
|
} else {
|
||||||
|
await box.put(key, annotationsJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads all annotations for a sheet (all pages).
|
||||||
|
///
|
||||||
|
/// Returns a map of page number to JSON string.
|
||||||
|
Future<Map<int, String>> readAllAnnotations(String sheetUuid) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final prefix = '${sheetUuid}_page_';
|
||||||
|
final result = <int, String>{};
|
||||||
|
|
||||||
|
for (final key in box.keys) {
|
||||||
|
if (key is String && key.startsWith(prefix)) {
|
||||||
|
final pageStr = key.substring(prefix.length);
|
||||||
|
final pageNumber = int.tryParse(pageStr);
|
||||||
|
if (pageNumber != null) {
|
||||||
|
final value = box.get(key);
|
||||||
|
if (value != null && value is String && value.isNotEmpty) {
|
||||||
|
result[pageNumber] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes all annotations for a sheet.
|
||||||
|
Future<void> deleteAllAnnotations(String sheetUuid) async {
|
||||||
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
|
final prefix = '${sheetUuid}_page_';
|
||||||
|
final keysToDelete =
|
||||||
|
box.keys.where((key) => key is String && key.startsWith(prefix));
|
||||||
|
|
||||||
|
for (final key in keysToDelete.toList()) {
|
||||||
|
await box.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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:flutter_fullscreen/flutter_fullscreen.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:pdfrx/pdfrx.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 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
|
||||||
import '../../shared/input/pedal_shortcuts.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/pdf_page_display.dart';
|
||||||
import 'widgets/touch_navigation_layer.dart';
|
import 'widgets/touch_navigation_layer.dart';
|
||||||
|
|
||||||
@@ -43,20 +41,21 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
int _currentPage = 1;
|
int _currentPage = 1;
|
||||||
int _totalPages = 1;
|
int _totalPages = 1;
|
||||||
bool _isPaintMode = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Initialize drawing controller with default configuration
|
|
||||||
_drawingController = DrawingController(
|
// Initialize drawing controllers
|
||||||
config: DrawConfig(
|
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
contentType: SimpleLine,
|
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
strokeWidth: 4.0,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
maxHistorySteps: 100, // Limit undo/redo history (default: 100)
|
|
||||||
);
|
|
||||||
FullScreen.addListener(this);
|
FullScreen.addListener(this);
|
||||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||||
_documentLoaded = _loadPdf();
|
_documentLoaded = _loadPdf();
|
||||||
@@ -64,12 +63,38 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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);
|
FullScreen.removeListener(this);
|
||||||
_document?.dispose();
|
_document?.dispose();
|
||||||
super.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
|
// PDF Loading
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -89,9 +114,64 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
_totalPages = _document!.pages.length;
|
_totalPages = _document!.pages.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load annotations for current page(s)
|
||||||
|
await _loadAnnotationsForCurrentPages();
|
||||||
|
|
||||||
return true;
|
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
|
// Fullscreen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -105,10 +185,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFullscreen() {
|
void _toggleFullscreen() {
|
||||||
if (_isPaintMode) {
|
|
||||||
_showSnackBar('Cannot enter fullscreen while in paint mode');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
FullScreen.setFullScreen(!widget.config.fullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,10 +192,19 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
// Navigation
|
// Navigation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _turnPage(int delta) {
|
Future<void> _turnPage(int delta) async {
|
||||||
setState(() {
|
// Save current annotations before turning
|
||||||
_currentPage = (_currentPage + delta).clamp(1, _totalPages);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isPaintMode) {
|
||||||
|
// Exiting paint mode - save annotations
|
||||||
|
_saveCurrentAnnotations();
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isPaintMode = !_isPaintMode);
|
setState(() => _isPaintMode = !_isPaintMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +235,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
widget.config.twoPageMode = !widget.config.twoPageMode;
|
widget.config.twoPageMode = !widget.config.twoPageMode;
|
||||||
_storageService.writeConfig(widget.config);
|
_storageService.writeConfig(widget.config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reload annotations for new mode
|
||||||
|
_loadAnnotationsForCurrentPages();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -156,7 +249,12 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
return PedalShortcuts(
|
return PedalShortcuts(
|
||||||
onPageForward: () => _turnPage(1),
|
onPageForward: () => _turnPage(1),
|
||||||
onPageBackward: () => _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(
|
icon: Icon(
|
||||||
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
),
|
),
|
||||||
tooltip: widget.config.fullscreen
|
tooltip:
|
||||||
? 'Exit Fullscreen'
|
widget.config.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
|
||||||
: 'Enter Fullscreen',
|
|
||||||
onPressed: _toggleFullscreen,
|
onPressed: _toggleFullscreen,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
||||||
tooltip: 'Toggle Paint Mode',
|
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
|
||||||
onPressed: _togglePaintMode,
|
onPressed: _togglePaintMode,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
|
||||||
),
|
),
|
||||||
tooltip: widget.config.twoPageMode
|
tooltip:
|
||||||
? 'Single Page Mode'
|
widget.config.twoPageMode ? 'Single Page Mode' : 'Two Page Mode',
|
||||||
: 'Two Page Mode',
|
|
||||||
onPressed: _toggleTwoPageMode,
|
onPressed: _toggleTwoPageMode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -220,30 +316,37 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
numPages: _totalPages,
|
numPages: _totalPages,
|
||||||
currentPageNumber: _currentPage,
|
currentPageNumber: _currentPage,
|
||||||
config: widget.config,
|
config: widget.config,
|
||||||
|
leftDrawingController: _leftDrawingController,
|
||||||
|
rightDrawingController:
|
||||||
|
widget.config.twoPageMode ? _rightDrawingController : null,
|
||||||
|
drawingEnabled: _isPaintMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Stack(
|
// When in paint mode, show the page display directly (DrawingBoard handles zoom/pan)
|
||||||
children: [
|
if (_isPaintMode) {
|
||||||
// Show touch navigation when not in paint mode
|
return pageDisplay;
|
||||||
Visibility(
|
}
|
||||||
visible: !_isPaintMode,
|
|
||||||
child: TouchNavigationLayer(
|
// When not in paint mode, wrap with touch navigation
|
||||||
|
return TouchNavigationLayer(
|
||||||
pageDisplay: pageDisplay,
|
pageDisplay: pageDisplay,
|
||||||
config: widget.config,
|
config: widget.config,
|
||||||
onToggleFullscreen: _toggleFullscreen,
|
onToggleFullscreen: _toggleFullscreen,
|
||||||
onExit: () => Navigator.pop(context),
|
onExit: () => Navigator.pop(context),
|
||||||
onPageTurn: _turnPage,
|
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),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
message,
|
message,
|
||||||
style: Theme.of(
|
style: Theme.of(context)
|
||||||
context,
|
.textTheme
|
||||||
).textTheme.titleMedium?.copyWith(color: Colors.red),
|
.titleMedium
|
||||||
|
?.copyWith(color: Colors.red),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -264,7 +368,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
|
|
||||||
void _showSnackBar(String message) {
|
void _showSnackBar(String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
import '../../../core/models/config.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 {
|
class PdfPageDisplay extends StatelessWidget {
|
||||||
final PdfDocument document;
|
final PdfDocument document;
|
||||||
final int numPages;
|
final int numPages;
|
||||||
final int currentPageNumber;
|
final int currentPageNumber;
|
||||||
final Config config;
|
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({
|
const PdfPageDisplay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.document,
|
required this.document,
|
||||||
required this.numPages,
|
required this.numPages,
|
||||||
required this.currentPageNumber,
|
required this.currentPageNumber,
|
||||||
required this.config,
|
required this.config,
|
||||||
|
this.leftDrawingController,
|
||||||
|
this.rightDrawingController,
|
||||||
|
this.drawingEnabled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Whether two-page mode is active and we have enough pages.
|
/// Whether two-page mode is active and we have enough pages.
|
||||||
@@ -25,55 +38,102 @@ class PdfPageDisplay extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return LayoutBuilder(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
builder: (context, constraints) {
|
||||||
|
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
final (leftSize, rightSize) = calculateScaledPageSizes(maxSize);
|
||||||
|
|
||||||
|
if (_showTwoPages) {
|
||||||
|
// Two-page mode: pages touch each other and are centered together
|
||||||
|
return Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [_buildLeftPage(), if (_showTwoPages) _buildRightPage()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLeftPage() {
|
|
||||||
return Expanded(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
PdfPageView(
|
_buildPage(
|
||||||
key: ValueKey(currentPageNumber),
|
|
||||||
document: document,
|
|
||||||
pageNumber: currentPageNumber,
|
pageNumber: currentPageNumber,
|
||||||
maximumDpi: 300,
|
pageSize: leftSize,
|
||||||
alignment: _showTwoPages ? Alignment.centerRight : Alignment.center,
|
controller: leftDrawingController,
|
||||||
),
|
),
|
||||||
_buildPageIndicator(currentPageNumber),
|
// 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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRightPage() {
|
// Single page mode
|
||||||
final rightPageNumber = currentPageNumber + 1;
|
return Center(
|
||||||
|
child: _buildPage(
|
||||||
|
pageNumber: currentPageNumber,
|
||||||
|
pageSize: leftSize,
|
||||||
|
controller: leftDrawingController,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Expanded(
|
Widget _buildPage({
|
||||||
|
required int pageNumber,
|
||||||
|
required Size pageSize,
|
||||||
|
required DrawingController? controller,
|
||||||
|
}) {
|
||||||
|
final pdfPage = SizedBox(
|
||||||
|
width: pageSize.width,
|
||||||
|
height: pageSize.height,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
PdfPageView(
|
PdfPageView(
|
||||||
key: ValueKey(rightPageNumber),
|
key: ValueKey(pageNumber),
|
||||||
document: document,
|
document: document,
|
||||||
pageNumber: rightPageNumber,
|
pageNumber: pageNumber,
|
||||||
maximumDpi: 300,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPageIndicator(int pageNumber) {
|
// Wrap with DrawingBoard
|
||||||
return Positioned.fill(
|
return DrawingBoard(
|
||||||
child: Container(
|
boardSize: pageSize,
|
||||||
alignment: Alignment.bottomCenter,
|
controller: controller,
|
||||||
padding: const EdgeInsets.only(bottom: 5),
|
drawingEnabled: drawingEnabled,
|
||||||
child: Text('$pageNumber / $numPages'),
|
minScale: 1.0,
|
||||||
|
maxScale: 3.0,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: pdfPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
import 'pdf_page_display.dart';
|
||||||
|
|
||||||
/// Callback for page turn events.
|
/// 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.
|
/// 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)
|
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
|
||||||
class TouchNavigationLayer extends StatelessWidget {
|
class TouchNavigationLayer extends StatelessWidget {
|
||||||
final PdfPageDisplay pageDisplay;
|
final PdfPageDisplay pageDisplay;
|
||||||
|
final Widget child;
|
||||||
final Config config;
|
final Config config;
|
||||||
final VoidCallback onToggleFullscreen;
|
final VoidCallback onToggleFullscreen;
|
||||||
final VoidCallback onExit;
|
final VoidCallback onExit;
|
||||||
@@ -22,6 +23,7 @@ class TouchNavigationLayer extends StatelessWidget {
|
|||||||
const TouchNavigationLayer({
|
const TouchNavigationLayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.pageDisplay,
|
required this.pageDisplay,
|
||||||
|
required this.child,
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.onToggleFullscreen,
|
required this.onToggleFullscreen,
|
||||||
required this.onExit,
|
required this.onExit,
|
||||||
@@ -33,7 +35,7 @@ class TouchNavigationLayer extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapUp: (details) => _handleTap(context, details),
|
onTapUp: (details) => _handleTap(context, details),
|
||||||
child: pageDisplay,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,14 +142,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.1"
|
||||||
flutter_drawing_board:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_drawing_board
|
|
||||||
sha256: "0fc6b73ac6a54f23d0357ff3f3a804156315f43212a417406062462fe2e3ca7b"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1+2"
|
|
||||||
flutter_fullscreen:
|
flutter_fullscreen:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ dependencies:
|
|||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
pdfrx: ^2.0.4
|
pdfrx: ^2.0.4
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
flutter_drawing_board: ^1.0.1+2
|
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
hive: ^2.2.3
|
hive: ^2.2.3
|
||||||
flutter_fullscreen: ^1.2.0
|
flutter_fullscreen: ^1.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user