Custom drawing implementation

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

View File

@@ -9,12 +9,14 @@ enum SecureStorageKey { url, jwt, email }
/// Service for managing local storage operations.
///
/// 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 {
// Hive box names
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
static const String _configBox = 'config';
static const String _changeQueueBox = 'changeQueue';
static const String _annotationsBox = 'annotations';
late final FlutterSecureStorage _secureStorage;
@@ -75,7 +77,8 @@ class StorageService {
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(_sheetAccessTimesBox);
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);
}
}
// ---------------------------------------------------------------------------
// 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
// 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)),
);
}
}

View File

@@ -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,
);
},
),
);
}
}

View File

@@ -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,
return LayoutBuilder(
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,
children: [_buildLeftPage(), if (_showTwoPages) _buildRightPage()],
);
}
Widget _buildLeftPage() {
return Expanded(
child: Stack(
children: [
PdfPageView(
key: ValueKey(currentPageNumber),
document: document,
_buildPage(
pageNumber: currentPageNumber,
maximumDpi: 300,
alignment: _showTwoPages ? Alignment.centerRight : Alignment.center,
pageSize: leftSize,
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() {
final rightPageNumber = currentPageNumber + 1;
// Single page mode
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(
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;
}
Widget _buildPageIndicator(int pageNumber) {
return Positioned.fill(
child: Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 5),
child: Text('$pageNumber / $numPages'),
// 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, Size pageSize) {
return Positioned(
bottom: 5,
left: 0,
right: 0,
child: Center(
child: Text(
'$pageNumber / $numPages',
style: const TextStyle(
fontSize: 12,
color: Colors.black54,
),
),
),
);
}

View File

@@ -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,
);
}

View File

@@ -142,14 +142,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:

View File

@@ -45,7 +45,6 @@ dependencies:
jwt_decoder: ^2.0.1
pdfrx: ^2.0.4
logging: ^1.3.0
flutter_drawing_board: ^1.0.1+2
flutter_launcher_icons: ^0.14.4
hive: ^2.2.3
flutter_fullscreen: ^1.2.0