982 lines
32 KiB
Dart
982 lines
32 KiB
Dart
|
|
import 'dart:convert';
|
||
|
|
import 'dart:io';
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter/services.dart' hide UndoManager;
|
||
|
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||
|
|
import 'package:uuid/uuid.dart';
|
||
|
|
|
||
|
|
import '../models/bookmark.dart';
|
||
|
|
import '../models/document.dart';
|
||
|
|
import '../models/ink_stroke.dart';
|
||
|
|
import '../models/pen_tool.dart';
|
||
|
|
import '../models/pressure_curve.dart';
|
||
|
|
import '../services/camera_service.dart';
|
||
|
|
import '../services/database_service.dart';
|
||
|
|
import '../services/pdf_service.dart';
|
||
|
|
import '../services/thumbnail_service.dart';
|
||
|
|
import '../services/undo_manager.dart';
|
||
|
|
import '../utils/stroke_stabilizer.dart';
|
||
|
|
import '../widgets/annotation_toolbar.dart';
|
||
|
|
import '../widgets/ink_canvas.dart';
|
||
|
|
import '../widgets/page_thumbnail_sidebar.dart';
|
||
|
|
import '../widgets/pdf_annotation_layer.dart';
|
||
|
|
import 'pdf_text_search.dart';
|
||
|
|
import 'split_view_screen.dart';
|
||
|
|
|
||
|
|
const _uuid = Uuid();
|
||
|
|
|
||
|
|
/// Actions available in the AppBar overflow menu.
|
||
|
|
enum _OverflowAction { pageManagement, cameraInsert, export }
|
||
|
|
|
||
|
|
/// Full-screen PDF viewer with ink annotation overlay.
|
||
|
|
///
|
||
|
|
/// Displays a PDF page-by-page with a transparent [PdfAnnotationLayer]
|
||
|
|
/// on top for pen/marker/eraser annotations. Annotations are stored
|
||
|
|
/// per page in normalized [0, 1] coordinates and exported via [PdfService].
|
||
|
|
/// Annotations and bookmarks are persisted to the database.
|
||
|
|
class PdfAnnotatorScreen extends StatefulWidget {
|
||
|
|
final String filePath;
|
||
|
|
final int initialPage;
|
||
|
|
|
||
|
|
const PdfAnnotatorScreen({
|
||
|
|
super.key,
|
||
|
|
required this.filePath,
|
||
|
|
this.initialPage = 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<PdfAnnotatorScreen> createState() => _PdfAnnotatorScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _PdfAnnotatorScreenState extends State<PdfAnnotatorScreen> {
|
||
|
|
final PdfService _pdfService = PdfService();
|
||
|
|
final CameraService _cameraService = CameraService();
|
||
|
|
final PdfViewerController _viewerController = PdfViewerController();
|
||
|
|
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||
|
|
|
||
|
|
int _currentPage = 0;
|
||
|
|
int _pageCount = 0;
|
||
|
|
int _pdfMutationVersion = 0;
|
||
|
|
String _fileName = '';
|
||
|
|
PenTool _currentTool = PenTool.pen;
|
||
|
|
Color _currentColor = Colors.black;
|
||
|
|
double _currentStrokeWidth = 2.0;
|
||
|
|
bool _filled = false;
|
||
|
|
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||
|
|
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||
|
|
InteractionMode _interactionMode = InteractionMode.draw;
|
||
|
|
double _zoomLevel = 1.0;
|
||
|
|
bool _showThumbnails = false;
|
||
|
|
|
||
|
|
String? _currentDocumentId;
|
||
|
|
final Map<int, UndoManager> _undoManagers = {};
|
||
|
|
final Map<int, List<InkStroke>> _annotations = {};
|
||
|
|
|
||
|
|
List<Bookmark> _bookmarks = [];
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_currentPage = widget.initialPage;
|
||
|
|
_loadPdfInfo();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _loadPdfInfo() async {
|
||
|
|
final info = await _pdfService.getPdfInfo(widget.filePath);
|
||
|
|
final count = await _pdfService.getPageCount(widget.filePath);
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_fileName = info['fileName'] as String;
|
||
|
|
_pageCount = count;
|
||
|
|
});
|
||
|
|
await _ensureDocumentExists();
|
||
|
|
await _loadAllAnnotations();
|
||
|
|
await _loadBookmarks();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _ensureDocumentExists() async {
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
final existing = await db.getDocumentByPath(widget.filePath);
|
||
|
|
if (existing == null) {
|
||
|
|
final now = DateTime.now();
|
||
|
|
final newDoc = Document(
|
||
|
|
id: _uuid.v4(),
|
||
|
|
filename: _fileName,
|
||
|
|
docType: 'pdf',
|
||
|
|
filePath: widget.filePath,
|
||
|
|
pageCount: _pageCount,
|
||
|
|
createdAt: now,
|
||
|
|
updatedAt: now,
|
||
|
|
);
|
||
|
|
await db.insertDocument(newDoc);
|
||
|
|
_currentDocumentId = newDoc.id;
|
||
|
|
} else {
|
||
|
|
_currentDocumentId = existing.id;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _loadAllAnnotations() async {
|
||
|
|
if (_currentDocumentId == null) return;
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
for (int i = 0; i < _pageCount; i++) {
|
||
|
|
final json = await db.getAnnotations(_currentDocumentId!, i);
|
||
|
|
if (json != null && json.isNotEmpty) {
|
||
|
|
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
|
||
|
|
_annotations[i] = list
|
||
|
|
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||
|
|
.toList();
|
||
|
|
_undoManagers[i] = UndoManager();
|
||
|
|
for (final stroke in _annotations[i]!) {
|
||
|
|
_undoManagers[i]!.addStroke(stroke);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (mounted) setState(() {});
|
||
|
|
}
|
||
|
|
|
||
|
|
void _saveCurrentPageAnnotations({int? page}) async {
|
||
|
|
if (_currentDocumentId == null) return;
|
||
|
|
// Capture the page index and serialize its strokes SYNCHRONOUSLY, before
|
||
|
|
// any await. Otherwise a concurrent navigation could change _currentPage
|
||
|
|
// while this is suspended, causing the wrong page's data to be saved.
|
||
|
|
final targetPage = page ?? _currentPage;
|
||
|
|
final documentId = _currentDocumentId!;
|
||
|
|
final strokesJson = jsonEncode(
|
||
|
|
_annotations[targetPage]?.map((s) => s.toJson()).toList() ?? [],
|
||
|
|
);
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
await db.saveAnnotations(documentId, targetPage, strokesJson);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onPageChanged(int page) {
|
||
|
|
// Save the page we are leaving, not the one we are navigating to.
|
||
|
|
_saveCurrentPageAnnotations(page: _currentPage);
|
||
|
|
setState(() {
|
||
|
|
_currentPage = page;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
UndoManager _getUndoManager(int page) {
|
||
|
|
return _undoManagers.putIfAbsent(page, UndoManager.new);
|
||
|
|
}
|
||
|
|
|
||
|
|
List<InkStroke> _getCurrentStrokes() {
|
||
|
|
return _annotations[_currentPage] ?? [];
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onStrokeComplete(InkStroke stroke) {
|
||
|
|
setState(() {
|
||
|
|
_annotations.putIfAbsent(_currentPage, () => []);
|
||
|
|
_annotations[_currentPage]!.add(stroke);
|
||
|
|
_getUndoManager(_currentPage).addStroke(stroke);
|
||
|
|
});
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||
|
|
setState(() {
|
||
|
|
final pageStrokes = _annotations[_currentPage];
|
||
|
|
if (pageStrokes == null) return;
|
||
|
|
final original = pageStrokes.where((s) => s.id == strokeId).firstOrNull;
|
||
|
|
if (original != null) {
|
||
|
|
_getUndoManager(
|
||
|
|
_currentPage,
|
||
|
|
).removeStroke(original, replacements: replacements);
|
||
|
|
_annotations[_currentPage] = _getUndoManager(
|
||
|
|
_currentPage,
|
||
|
|
).currentStrokes.toList();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _undo() {
|
||
|
|
setState(() {
|
||
|
|
_getUndoManager(_currentPage).undo();
|
||
|
|
_annotations[_currentPage] = _getUndoManager(
|
||
|
|
_currentPage,
|
||
|
|
).currentStrokes.toList();
|
||
|
|
});
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _redo() {
|
||
|
|
setState(() {
|
||
|
|
_getUndoManager(_currentPage).redo();
|
||
|
|
_annotations[_currentPage] = _getUndoManager(
|
||
|
|
_currentPage,
|
||
|
|
).currentStrokes.toList();
|
||
|
|
});
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Bookmarks --
|
||
|
|
|
||
|
|
bool get _isCurrentPageBookmarked =>
|
||
|
|
_bookmarks.any((b) => b.pageNumber == _currentPage);
|
||
|
|
|
||
|
|
Future<void> _loadBookmarks() async {
|
||
|
|
if (_currentDocumentId == null) return;
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
final bookmarks = await db.getBookmarks(_currentDocumentId!);
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_bookmarks = bookmarks;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _toggleBookmark() async {
|
||
|
|
if (_currentDocumentId == null) return;
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
|
||
|
|
if (_isCurrentPageBookmarked) {
|
||
|
|
final existing = _bookmarks.firstWhere(
|
||
|
|
(b) => b.pageNumber == _currentPage,
|
||
|
|
);
|
||
|
|
await db.deleteBookmark(existing.id);
|
||
|
|
setState(() {
|
||
|
|
_bookmarks.removeWhere((b) => b.id == existing.id);
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
final label = await _showBookmarkDialog();
|
||
|
|
if (label == null) return;
|
||
|
|
|
||
|
|
final bookmark = Bookmark(
|
||
|
|
id: _uuid.v4(),
|
||
|
|
documentId: _currentDocumentId!,
|
||
|
|
pageNumber: _currentPage,
|
||
|
|
label: label,
|
||
|
|
createdAt: DateTime.now(),
|
||
|
|
);
|
||
|
|
await db.insertBookmark(bookmark);
|
||
|
|
setState(() {
|
||
|
|
_bookmarks.add(bookmark);
|
||
|
|
_bookmarks.sort((a, b) => a.pageNumber.compareTo(b.pageNumber));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<String?> _showBookmarkDialog() async {
|
||
|
|
final controller = TextEditingController();
|
||
|
|
return showDialog<String>(
|
||
|
|
context: context,
|
||
|
|
builder: (context) {
|
||
|
|
return AlertDialog(
|
||
|
|
title: const Text('Add Bookmark'),
|
||
|
|
content: TextField(
|
||
|
|
controller: controller,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
hintText: 'Label (optional)',
|
||
|
|
labelText: 'Bookmark label',
|
||
|
|
),
|
||
|
|
autofocus: true,
|
||
|
|
),
|
||
|
|
actions: [
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(context).pop(),
|
||
|
|
child: const Text('Cancel'),
|
||
|
|
),
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(context).pop(controller.text),
|
||
|
|
child: const Text('Add'),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _deleteBookmark(Bookmark bookmark) async {
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
await db.deleteBookmark(bookmark.id);
|
||
|
|
setState(() {
|
||
|
|
_bookmarks.removeWhere((b) => b.id == bookmark.id);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
void _jumpToPage(int page) {
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
_viewerController.jumpToPage(page + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Zoom --
|
||
|
|
|
||
|
|
void _zoomIn() {
|
||
|
|
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
|
||
|
|
_viewerController.zoomLevel = newLevel;
|
||
|
|
setState(() => _zoomLevel = newLevel);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _zoomOut() {
|
||
|
|
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
|
||
|
|
_viewerController.zoomLevel = newLevel;
|
||
|
|
setState(() => _zoomLevel = newLevel);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _zoomFitWidth() {
|
||
|
|
_viewerController.zoomLevel = 1.0;
|
||
|
|
setState(() => _zoomLevel = 1.0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Search --
|
||
|
|
|
||
|
|
void _openSearch() {
|
||
|
|
showDialog(
|
||
|
|
context: context,
|
||
|
|
builder: (_) => PdfTextSearchDialog(viewerController: _viewerController),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Export --
|
||
|
|
|
||
|
|
Future<void> _exportPdf() async {
|
||
|
|
try {
|
||
|
|
final outputPath = await _pdfService.exportAnnotatedPdf(
|
||
|
|
widget.filePath,
|
||
|
|
_annotations,
|
||
|
|
);
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(SnackBar(content: Text('Exported to: $outputPath')));
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Page Management --
|
||
|
|
|
||
|
|
void _showPageManagementSheet() {
|
||
|
|
showModalBottomSheet(
|
||
|
|
context: context,
|
||
|
|
builder: (context) {
|
||
|
|
final canDelete = _pageCount > 1;
|
||
|
|
return SafeArea(
|
||
|
|
child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
ListTile(
|
||
|
|
leading: const Icon(Icons.rotate_right),
|
||
|
|
title: const Text('Rotate Page 90\u00B0'),
|
||
|
|
subtitle: Text('Page ${_currentPage + 1}'),
|
||
|
|
onTap: () {
|
||
|
|
Navigator.of(context).pop();
|
||
|
|
_rotateCurrentPage();
|
||
|
|
},
|
||
|
|
),
|
||
|
|
ListTile(
|
||
|
|
leading: Icon(
|
||
|
|
Icons.delete_outline,
|
||
|
|
color: canDelete ? null : Colors.grey,
|
||
|
|
),
|
||
|
|
title: Text(
|
||
|
|
'Delete Page',
|
||
|
|
style: TextStyle(color: canDelete ? null : Colors.grey),
|
||
|
|
),
|
||
|
|
subtitle: Text(
|
||
|
|
canDelete
|
||
|
|
? 'Page ${_currentPage + 1}'
|
||
|
|
: 'Cannot delete the only page',
|
||
|
|
),
|
||
|
|
enabled: canDelete,
|
||
|
|
onTap: canDelete
|
||
|
|
? () {
|
||
|
|
Navigator.of(context).pop();
|
||
|
|
_deleteCurrentPage();
|
||
|
|
}
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
ListTile(
|
||
|
|
leading: const Icon(Icons.note_add_outlined),
|
||
|
|
title: const Text('Insert Blank Page After Current'),
|
||
|
|
subtitle: Text('After page ${_currentPage + 1}'),
|
||
|
|
onTap: () {
|
||
|
|
Navigator.of(context).pop();
|
||
|
|
_insertBlankPageAfterCurrent();
|
||
|
|
},
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Transform a stroke's normalized [0,1] points to match a 90° clockwise
|
||
|
|
/// page rotation: a point at (x, y) maps to (1 - y, x). Used to keep
|
||
|
|
/// existing annotations glued to the page content after the page itself is
|
||
|
|
/// physically rotated (PDF /Rotate).
|
||
|
|
InkStroke _rotateStroke90CW(InkStroke stroke) {
|
||
|
|
return stroke.copyWith(
|
||
|
|
points: stroke.points
|
||
|
|
.map((p) => p.copyWith(x: 1.0 - p.y, y: p.x))
|
||
|
|
.toList(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _rotateCurrentPage() async {
|
||
|
|
final rotatedPage = _currentPage;
|
||
|
|
final success = await _pdfService.rotatePage(widget.filePath, rotatedPage);
|
||
|
|
if (!success || !mounted) return;
|
||
|
|
// Invalidate thumbnail for the rotated page.
|
||
|
|
if (_currentDocumentId != null) {
|
||
|
|
await ThumbnailService.invalidatePage(_currentDocumentId!, rotatedPage);
|
||
|
|
}
|
||
|
|
setState(() {
|
||
|
|
_pdfMutationVersion++;
|
||
|
|
// The page is physically rotated 90° CW, so transform existing stored
|
||
|
|
// annotations the same way to keep them aligned with the page content.
|
||
|
|
// New strokes drawn afterwards are already captured in the rotated frame.
|
||
|
|
final existing = _annotations[rotatedPage];
|
||
|
|
if (existing != null && existing.isNotEmpty) {
|
||
|
|
_annotations[rotatedPage] = existing.map(_rotateStroke90CW).toList();
|
||
|
|
// Undo history holds pre-rotation coordinates; reset it for this page
|
||
|
|
// so undo/redo cannot reintroduce misaligned strokes.
|
||
|
|
_undoManagers.remove(rotatedPage);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
// Persist the transformed annotations for the rotated page.
|
||
|
|
_saveCurrentPageAnnotations(page: rotatedPage);
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text('Rotated page ${_currentPage + 1}')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _deleteCurrentPage() async {
|
||
|
|
// Confirm.
|
||
|
|
final confirmed = await showDialog<bool>(
|
||
|
|
context: context,
|
||
|
|
builder: (context) => AlertDialog(
|
||
|
|
title: const Text('Delete Page'),
|
||
|
|
content: Text(
|
||
|
|
'Delete page ${_currentPage + 1}? This cannot be undone.',
|
||
|
|
),
|
||
|
|
actions: [
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(context).pop(false),
|
||
|
|
child: const Text('Cancel'),
|
||
|
|
),
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(context).pop(true),
|
||
|
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (confirmed != true) return;
|
||
|
|
|
||
|
|
final success = await _pdfService.deletePage(widget.filePath, _currentPage);
|
||
|
|
if (!success || !mounted) return;
|
||
|
|
|
||
|
|
if (_currentDocumentId != null) {
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
// Delete annotation/bookmark/ocr data for the removed page.
|
||
|
|
await db.deletePageData(_currentDocumentId!, _currentPage);
|
||
|
|
// Remap higher-indexed data down by 1.
|
||
|
|
await db.remapAnnotationsAfterDelete(_currentDocumentId!, _currentPage);
|
||
|
|
await db.remapBookmarksAfterDelete(_currentDocumentId!, _currentPage);
|
||
|
|
// Update stored page count.
|
||
|
|
final newCount = _pageCount - 1;
|
||
|
|
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
|
||
|
|
// Invalidate all thumbnails (page indices shifted).
|
||
|
|
await ThumbnailService.invalidateAll(_currentDocumentId!);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Shift in-memory annotations down.
|
||
|
|
final newAnnotations = <int, List<InkStroke>>{};
|
||
|
|
for (final entry in _annotations.entries) {
|
||
|
|
if (entry.key < _currentPage) {
|
||
|
|
newAnnotations[entry.key] = entry.value;
|
||
|
|
} else if (entry.key > _currentPage) {
|
||
|
|
newAnnotations[entry.key - 1] = entry.value;
|
||
|
|
}
|
||
|
|
// entry.key == _currentPage is dropped.
|
||
|
|
}
|
||
|
|
_annotations
|
||
|
|
..clear()
|
||
|
|
..addAll(newAnnotations);
|
||
|
|
|
||
|
|
// Shift undo managers.
|
||
|
|
final newUndoManagers = <int, UndoManager>{};
|
||
|
|
for (final entry in _undoManagers.entries) {
|
||
|
|
if (entry.key < _currentPage) {
|
||
|
|
newUndoManagers[entry.key] = entry.value;
|
||
|
|
} else if (entry.key > _currentPage) {
|
||
|
|
newUndoManagers[entry.key - 1] = entry.value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_undoManagers
|
||
|
|
..clear()
|
||
|
|
..addAll(newUndoManagers);
|
||
|
|
|
||
|
|
// Shift bookmarks in memory.
|
||
|
|
_bookmarks.removeWhere((b) => b.pageNumber == _currentPage);
|
||
|
|
for (int i = 0; i < _bookmarks.length; i++) {
|
||
|
|
if (_bookmarks[i].pageNumber > _currentPage) {
|
||
|
|
_bookmarks[i] = Bookmark(
|
||
|
|
id: _bookmarks[i].id,
|
||
|
|
documentId: _bookmarks[i].documentId,
|
||
|
|
pageNumber: _bookmarks[i].pageNumber - 1,
|
||
|
|
label: _bookmarks[i].label,
|
||
|
|
color: _bookmarks[i].color,
|
||
|
|
createdAt: _bookmarks[i].createdAt,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setState(() {
|
||
|
|
_pageCount = _pageCount - 1;
|
||
|
|
if (_currentPage >= _pageCount) {
|
||
|
|
_currentPage = _pageCount - 1;
|
||
|
|
}
|
||
|
|
_pdfMutationVersion++;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(const SnackBar(content: Text('Page deleted')));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _insertBlankPageAfterCurrent() async {
|
||
|
|
final success = await _pdfService.insertBlankPage(
|
||
|
|
widget.filePath,
|
||
|
|
_currentPage,
|
||
|
|
);
|
||
|
|
if (!success || !mounted) return;
|
||
|
|
|
||
|
|
final insertedIndex = _currentPage + 1;
|
||
|
|
if (_currentDocumentId != null) {
|
||
|
|
final db = await DatabaseService.getInstance();
|
||
|
|
await db.remapAnnotationsAfterInsert(_currentDocumentId!, insertedIndex);
|
||
|
|
await db.remapBookmarksAfterInsert(_currentDocumentId!, insertedIndex);
|
||
|
|
final newCount = _pageCount + 1;
|
||
|
|
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
|
||
|
|
await ThumbnailService.invalidateAll(_currentDocumentId!);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Shift in-memory annotations up by 1 for pages >= insertedIndex.
|
||
|
|
final newAnnotations = <int, List<InkStroke>>{};
|
||
|
|
for (final entry in _annotations.entries) {
|
||
|
|
if (entry.key < insertedIndex) {
|
||
|
|
newAnnotations[entry.key] = entry.value;
|
||
|
|
} else {
|
||
|
|
newAnnotations[entry.key + 1] = entry.value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_annotations
|
||
|
|
..clear()
|
||
|
|
..addAll(newAnnotations);
|
||
|
|
|
||
|
|
final newUndoManagers = <int, UndoManager>{};
|
||
|
|
for (final entry in _undoManagers.entries) {
|
||
|
|
if (entry.key < insertedIndex) {
|
||
|
|
newUndoManagers[entry.key] = entry.value;
|
||
|
|
} else {
|
||
|
|
newUndoManagers[entry.key + 1] = entry.value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_undoManagers
|
||
|
|
..clear()
|
||
|
|
..addAll(newUndoManagers);
|
||
|
|
|
||
|
|
// Shift bookmarks in memory.
|
||
|
|
for (int i = 0; i < _bookmarks.length; i++) {
|
||
|
|
if (_bookmarks[i].pageNumber >= insertedIndex) {
|
||
|
|
_bookmarks[i] = Bookmark(
|
||
|
|
id: _bookmarks[i].id,
|
||
|
|
documentId: _bookmarks[i].documentId,
|
||
|
|
pageNumber: _bookmarks[i].pageNumber + 1,
|
||
|
|
label: _bookmarks[i].label,
|
||
|
|
color: _bookmarks[i].color,
|
||
|
|
createdAt: _bookmarks[i].createdAt,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setState(() {
|
||
|
|
_pageCount = _pageCount + 1;
|
||
|
|
_pdfMutationVersion++;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(
|
||
|
|
content: Text('Blank page inserted after page ${_currentPage + 1}'),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Camera Insert --
|
||
|
|
|
||
|
|
Future<void> _showCameraInsertDialog() async {
|
||
|
|
final source = await showDialog<String>(
|
||
|
|
context: context,
|
||
|
|
builder: (context) => SimpleDialog(
|
||
|
|
title: const Text('Insert Image'),
|
||
|
|
children: [
|
||
|
|
SimpleDialogOption(
|
||
|
|
onPressed: () => Navigator.of(context).pop('camera'),
|
||
|
|
child: const ListTile(
|
||
|
|
leading: Icon(Icons.camera_alt),
|
||
|
|
title: Text('Camera'),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
SimpleDialogOption(
|
||
|
|
onPressed: () => Navigator.of(context).pop('gallery'),
|
||
|
|
child: const ListTile(
|
||
|
|
leading: Icon(Icons.photo_library),
|
||
|
|
title: Text('Gallery'),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (source == null || !mounted) return;
|
||
|
|
|
||
|
|
final String? imagePath;
|
||
|
|
if (source == 'camera') {
|
||
|
|
imagePath = await _cameraService.capturePhoto();
|
||
|
|
} else {
|
||
|
|
imagePath = await _cameraService.pickFromGallery();
|
||
|
|
}
|
||
|
|
if (imagePath == null || !mounted) return;
|
||
|
|
|
||
|
|
final result = await _pdfService.insertImageOnPage(
|
||
|
|
widget.filePath,
|
||
|
|
_currentPage,
|
||
|
|
imagePath,
|
||
|
|
);
|
||
|
|
if (result != null && mounted) {
|
||
|
|
if (_currentDocumentId != null) {
|
||
|
|
await ThumbnailService.invalidatePage(
|
||
|
|
_currentDocumentId!,
|
||
|
|
_currentPage,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
setState(() {
|
||
|
|
_pdfMutationVersion++;
|
||
|
|
});
|
||
|
|
// Save current annotations so they overlay the image.
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text('Image inserted on page ${_currentPage + 1}')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} else if (mounted) {
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(const SnackBar(content: Text('Failed to insert image')));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Bookmark drawer --
|
||
|
|
|
||
|
|
Widget _buildBookmarkDrawer() {
|
||
|
|
return Drawer(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Text(
|
||
|
|
'Bookmarks',
|
||
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: _bookmarks.isEmpty
|
||
|
|
? Center(
|
||
|
|
child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Icon(
|
||
|
|
Icons.bookmark_border,
|
||
|
|
size: 48,
|
||
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
const Text(
|
||
|
|
'No bookmarks yet',
|
||
|
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
const Text(
|
||
|
|
'Tap the bookmark icon in the toolbar\nto bookmark the current page.',
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: ListView.builder(
|
||
|
|
itemCount: _bookmarks.length,
|
||
|
|
itemBuilder: (context, index) {
|
||
|
|
final bookmark = _bookmarks[index];
|
||
|
|
return ListTile(
|
||
|
|
leading: CircleAvatar(
|
||
|
|
backgroundColor: Color(bookmark.color),
|
||
|
|
radius: 6,
|
||
|
|
),
|
||
|
|
title: Text(
|
||
|
|
bookmark.label.isEmpty
|
||
|
|
? 'Page ${bookmark.pageNumber + 1}'
|
||
|
|
: bookmark.label,
|
||
|
|
),
|
||
|
|
subtitle: Text('Page ${bookmark.pageNumber + 1}'),
|
||
|
|
trailing: IconButton(
|
||
|
|
icon: const Icon(Icons.delete_outline),
|
||
|
|
tooltip: 'Delete bookmark',
|
||
|
|
onPressed: () => _deleteBookmark(bookmark),
|
||
|
|
),
|
||
|
|
onTap: () {
|
||
|
|
Navigator.of(context).pop();
|
||
|
|
_jumpToPage(bookmark.pageNumber);
|
||
|
|
},
|
||
|
|
onLongPress: () => _deleteBookmark(bookmark),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- UI --
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final undoManager = _getUndoManager(_currentPage);
|
||
|
|
return Scaffold(
|
||
|
|
key: _scaffoldKey,
|
||
|
|
appBar: AppBar(
|
||
|
|
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||
|
|
actions: [
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.vertical_split),
|
||
|
|
tooltip: 'Open in Split View',
|
||
|
|
onPressed: () {
|
||
|
|
if (_currentDocumentId == null) return;
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
Navigator.of(context).push(
|
||
|
|
MaterialPageRoute(
|
||
|
|
builder: (_) => SplitViewScreen(
|
||
|
|
filePath: widget.filePath,
|
||
|
|
documentId: _currentDocumentId!,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.search),
|
||
|
|
tooltip: 'Search in PDF (Ctrl+F)',
|
||
|
|
onPressed: _openSearch,
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: Icon(
|
||
|
|
_isCurrentPageBookmarked ? Icons.bookmark : Icons.bookmark_border,
|
||
|
|
),
|
||
|
|
tooltip: 'Toggle bookmark',
|
||
|
|
onPressed: _toggleBookmark,
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.menu_book),
|
||
|
|
tooltip: 'Bookmarks',
|
||
|
|
onPressed: () => _scaffoldKey.currentState?.openEndDrawer(),
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: Icon(
|
||
|
|
_showThumbnails
|
||
|
|
? Icons.view_sidebar
|
||
|
|
: Icons.view_sidebar_outlined,
|
||
|
|
),
|
||
|
|
tooltip: 'Toggle page thumbnails',
|
||
|
|
onPressed: () => setState(() => _showThumbnails = !_showThumbnails),
|
||
|
|
),
|
||
|
|
PopupMenuButton<_OverflowAction>(
|
||
|
|
icon: const Icon(Icons.more_vert),
|
||
|
|
tooltip: 'More actions',
|
||
|
|
onSelected: (action) {
|
||
|
|
switch (action) {
|
||
|
|
case _OverflowAction.pageManagement:
|
||
|
|
_showPageManagementSheet();
|
||
|
|
case _OverflowAction.cameraInsert:
|
||
|
|
_showCameraInsertDialog();
|
||
|
|
case _OverflowAction.export:
|
||
|
|
_exportPdf();
|
||
|
|
}
|
||
|
|
},
|
||
|
|
itemBuilder: (context) => const [
|
||
|
|
PopupMenuItem(
|
||
|
|
value: _OverflowAction.pageManagement,
|
||
|
|
child: ListTile(
|
||
|
|
leading: Icon(Icons.pages),
|
||
|
|
title: Text('Page Management'),
|
||
|
|
contentPadding: EdgeInsets.zero,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
PopupMenuItem(
|
||
|
|
value: _OverflowAction.cameraInsert,
|
||
|
|
child: ListTile(
|
||
|
|
leading: Icon(Icons.camera_alt),
|
||
|
|
title: Text('Insert Image'),
|
||
|
|
contentPadding: EdgeInsets.zero,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
PopupMenuItem(
|
||
|
|
value: _OverflowAction.export,
|
||
|
|
child: ListTile(
|
||
|
|
leading: Icon(Icons.save_alt),
|
||
|
|
title: Text('Export PDF'),
|
||
|
|
contentPadding: EdgeInsets.zero,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
endDrawer: _buildBookmarkDrawer(),
|
||
|
|
body: CallbackShortcuts(
|
||
|
|
bindings: {
|
||
|
|
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
|
||
|
|
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
|
||
|
|
const SingleActivator(
|
||
|
|
LogicalKeyboardKey.keyZ,
|
||
|
|
control: true,
|
||
|
|
shift: true,
|
||
|
|
): _redo,
|
||
|
|
const SingleActivator(LogicalKeyboardKey.keyF, control: true):
|
||
|
|
_openSearch,
|
||
|
|
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
|
||
|
|
_saveCurrentPageAnnotations,
|
||
|
|
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||
|
|
if (_interactionMode != InteractionMode.navigate) {
|
||
|
|
setState(() => _interactionMode = InteractionMode.navigate);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
},
|
||
|
|
child: Focus(
|
||
|
|
autofocus: true,
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
AnnotationToolbar(
|
||
|
|
currentTool: _currentTool,
|
||
|
|
currentColor: _currentColor,
|
||
|
|
currentStrokeWidth: _currentStrokeWidth,
|
||
|
|
filled: _filled,
|
||
|
|
pressureCurveType: _pressureCurveType,
|
||
|
|
stabilizationLevel: _stabilizationLevel,
|
||
|
|
canUndo: undoManager.canUndo,
|
||
|
|
canRedo: undoManager.canRedo,
|
||
|
|
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||
|
|
onColorChanged: (color) =>
|
||
|
|
setState(() => _currentColor = color),
|
||
|
|
onStrokeWidthChanged: (w) =>
|
||
|
|
setState(() => _currentStrokeWidth = w),
|
||
|
|
onFilledChanged: (f) => setState(() => _filled = f),
|
||
|
|
onPressureCurveChanged: (v) =>
|
||
|
|
setState(() => _pressureCurveType = v),
|
||
|
|
onStabilizationChanged: (v) =>
|
||
|
|
setState(() => _stabilizationLevel = v),
|
||
|
|
onUndo: _undo,
|
||
|
|
onRedo: _redo,
|
||
|
|
onPreviousPage: _currentPage > 0
|
||
|
|
? () => _viewerController.previousPage()
|
||
|
|
: null,
|
||
|
|
onNextPage: _currentPage < _pageCount - 1
|
||
|
|
? () => _viewerController.nextPage()
|
||
|
|
: null,
|
||
|
|
pageInfo: '${_currentPage + 1} / $_pageCount',
|
||
|
|
interactionMode: _interactionMode,
|
||
|
|
onInteractionModeChanged: (mode) =>
|
||
|
|
setState(() => _interactionMode = mode),
|
||
|
|
onZoomIn: _zoomIn,
|
||
|
|
onZoomOut: _zoomOut,
|
||
|
|
onZoomFitWidth: _zoomFitWidth,
|
||
|
|
zoomLabel: '${(_zoomLevel * 100).round()}%',
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
if (_showThumbnails && _currentDocumentId != null)
|
||
|
|
PageThumbnailSidebar(
|
||
|
|
documentId: _currentDocumentId!,
|
||
|
|
filePath: widget.filePath,
|
||
|
|
pageCount: _pageCount,
|
||
|
|
currentPage: _currentPage,
|
||
|
|
onPageTap: _jumpToPage,
|
||
|
|
bookmarkedPages: _bookmarks
|
||
|
|
.map((b) => b.pageNumber)
|
||
|
|
.toSet(),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: Stack(
|
||
|
|
children: [
|
||
|
|
SfPdfViewer.file(
|
||
|
|
File(widget.filePath),
|
||
|
|
key: ValueKey('pdf-$_pdfMutationVersion'),
|
||
|
|
controller: _viewerController,
|
||
|
|
initialPageNumber: _currentPage + 1,
|
||
|
|
onPageChanged: (PdfPageChangedDetails details) {
|
||
|
|
_onPageChanged(details.newPageNumber - 1);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
Positioned.fill(
|
||
|
|
child: _interactionMode == InteractionMode.navigate
|
||
|
|
? IgnorePointer(
|
||
|
|
child: PdfAnnotationLayer(
|
||
|
|
strokes: _getCurrentStrokes(),
|
||
|
|
onStrokeComplete: _onStrokeComplete,
|
||
|
|
onErase: _onErase,
|
||
|
|
tool: _currentTool,
|
||
|
|
color: _currentColor,
|
||
|
|
strokeWidth: _currentStrokeWidth,
|
||
|
|
filled: _filled,
|
||
|
|
interactionMode: _interactionMode,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: PdfAnnotationLayer(
|
||
|
|
strokes: _getCurrentStrokes(),
|
||
|
|
onStrokeComplete: _onStrokeComplete,
|
||
|
|
onErase: _onErase,
|
||
|
|
tool: _currentTool,
|
||
|
|
color: _currentColor,
|
||
|
|
strokeWidth: _currentStrokeWidth,
|
||
|
|
filled: _filled,
|
||
|
|
interactionMode: _interactionMode,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_saveCurrentPageAnnotations();
|
||
|
|
_viewerController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
}
|