From 3507e929b15423e42b87afa652aa535730f95188 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 10:06:02 +0800 Subject: [PATCH] refactor: delete dead old PDF annotator screen Now that every PDF entry point (home import, home open, search jump) routes to PenEditorScreen, the old SfPdfViewer-based annotator is unreachable. Remove it and the two widgets it solely owned: - screens/pdf_annotator_screen.dart (981 lines) - widgets/page_thumbnail_sidebar.dart - widgets/pdf_annotation_layer.dart annotation_toolbar and ink_canvas stay (still used by the note/ppt/ split-view screens). No references remain to the deleted files. flutter analyze: 0 issues. Full suite: 258/258. --- lib/screens/pdf_annotator_screen.dart | 981 ------------------------ lib/widgets/page_thumbnail_sidebar.dart | 206 ----- lib/widgets/pdf_annotation_layer.dart | 162 ---- 3 files changed, 1349 deletions(-) delete mode 100644 lib/screens/pdf_annotator_screen.dart delete mode 100644 lib/widgets/page_thumbnail_sidebar.dart delete mode 100644 lib/widgets/pdf_annotation_layer.dart diff --git a/lib/screens/pdf_annotator_screen.dart b/lib/screens/pdf_annotator_screen.dart deleted file mode 100644 index 38244b0..0000000 --- a/lib/screens/pdf_annotator_screen.dart +++ /dev/null @@ -1,981 +0,0 @@ -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 createState() => _PdfAnnotatorScreenState(); -} - -class _PdfAnnotatorScreenState extends State { - final PdfService _pdfService = PdfService(); - final CameraService _cameraService = CameraService(); - final PdfViewerController _viewerController = PdfViewerController(); - final GlobalKey _scaffoldKey = GlobalKey(); - - 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 _undoManagers = {}; - final Map> _annotations = {}; - - List _bookmarks = []; - - @override - void initState() { - super.initState(); - _currentPage = widget.initialPage; - _loadPdfInfo(); - } - - Future _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 _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 _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 list = jsonDecode(json) as List; - _annotations[i] = list - .map((s) => InkStroke.fromJson(s as Map)) - .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 _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 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 _loadBookmarks() async { - if (_currentDocumentId == null) return; - final db = await DatabaseService.getInstance(); - final bookmarks = await db.getBookmarks(_currentDocumentId!); - if (mounted) { - setState(() { - _bookmarks = bookmarks; - }); - } - } - - Future _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 _showBookmarkDialog() async { - final controller = TextEditingController(); - return showDialog( - 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 _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 _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 _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 _deleteCurrentPage() async { - // Confirm. - final confirmed = await showDialog( - 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 = >{}; - 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 = {}; - 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 _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 = >{}; - 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 = {}; - 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 _showCameraInsertDialog() async { - final source = await showDialog( - 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(); - } -} diff --git a/lib/widgets/page_thumbnail_sidebar.dart b/lib/widgets/page_thumbnail_sidebar.dart deleted file mode 100644 index 0d886a6..0000000 --- a/lib/widgets/page_thumbnail_sidebar.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../services/thumbnail_service.dart'; - -/// Vertical sidebar showing page thumbnails for quick navigation. -/// -/// Thumbnails are lazily generated and cached on disk. The current page is -/// highlighted with a blue border, and bookmarked pages show a colored dot. -class PageThumbnailSidebar extends StatefulWidget { - final String documentId; - final String filePath; - final int pageCount; - final int currentPage; - final ValueChanged onPageTap; - final Set bookmarkedPages; - - const PageThumbnailSidebar({ - super.key, - required this.documentId, - required this.filePath, - required this.pageCount, - required this.currentPage, - required this.onPageTap, - this.bookmarkedPages = const {}, - }); - - @override - State createState() => _PageThumbnailSidebarState(); -} - -class _PageThumbnailSidebarState extends State { - /// Cached thumbnail image data keyed by page index. - final Map _cache = {}; - - /// Pages currently being generated (to avoid duplicate work). - final Set _loading = {}; - - /// Pages that permanently failed thumbnail generation (null result or throw). - /// Skipped on subsequent rebuilds to avoid a retry storm. - final Set _failed = {}; - - @override - void didUpdateWidget(PageThumbnailSidebar oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.documentId != widget.documentId) { - _cache.clear(); - _loading.clear(); - _failed.clear(); - } - } - - Future _loadThumbnail(int pageIndex) async { - if (_cache.containsKey(pageIndex) || - _loading.contains(pageIndex) || - _failed.contains(pageIndex)) { - return; - } - _loading.add(pageIndex); - - try { - // Check disk cache first. - final cached = await ThumbnailService.getCached( - widget.documentId, - pageIndex, - ); - if (cached != null && mounted) { - setState(() { - _cache[pageIndex] = FileImage(cached); - }); - _loading.remove(pageIndex); - return; - } - - // Generate from the PDF. - final bytes = await ThumbnailService.generate( - widget.filePath, - pageIndex, - maxWidth: 160, - ); - if (bytes != null) { - await ThumbnailService.cacheThumbnail( - widget.documentId, - pageIndex, - bytes, - ); - if (mounted) { - setState(() { - _cache[pageIndex] = MemoryImage(bytes); - }); - } - } else { - // Null result means generation failed permanently for this page. - _failed.add(pageIndex); - } - } catch (_) { - // Any exception is treated as a permanent failure to avoid retry storms. - _failed.add(pageIndex); - } finally { - _loading.remove(pageIndex); - } - } - - @override - Widget build(BuildContext context) { - return Container( - width: 120, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - border: Border( - right: BorderSide(color: Theme.of(context).dividerColor, width: 1), - ), - ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.pageCount, - itemBuilder: (context, index) { - _loadThumbnail(index); - final isCurrentPage = index == widget.currentPage; - final isBookmarked = widget.bookmarkedPages.contains(index); - - return GestureDetector( - onTap: () => widget.onPageTap(index), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all( - color: isCurrentPage - ? Theme.of(context).colorScheme.primary - : Colors.grey.shade400, - width: isCurrentPage ? 2.5 : 1.0, - ), - borderRadius: BorderRadius.circular(4), - ), - child: Stack( - children: [ - // Thumbnail image or placeholder. - AspectRatio( - aspectRatio: 8.5 / 11, // US Letter-ish ratio - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: _cache.containsKey(index) - ? Image(image: _cache[index]!, fit: BoxFit.cover) - : Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - child: Center( - child: Text( - '${index + 1}', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ), - ), - // Page number overlay. - Positioned( - bottom: 2, - right: 2, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(3), - ), - child: Text( - '${index + 1}', - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - ), - ), - // Bookmark indicator. - if (isBookmarked) - Positioned( - top: 2, - left: 2, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/widgets/pdf_annotation_layer.dart b/lib/widgets/pdf_annotation_layer.dart deleted file mode 100644 index 3159a2c..0000000 --- a/lib/widgets/pdf_annotation_layer.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/ink_point.dart'; -import '../models/ink_stroke.dart'; -import '../models/pen_tool.dart'; -import '../widgets/ink_canvas.dart'; - -/// Transparent overlay widget positioned on top of the PDF viewer. -/// -/// Reuses the existing [InkCanvas] widget for ink rendering. -/// Coordinates are normalized to [0, 1] relative to the overlay size, -/// enabling correct mapping to PDF page coordinates during export. -class PdfAnnotationLayer extends StatefulWidget { - final List strokes; - final void Function(InkStroke stroke)? onStrokeComplete; - final void Function(String strokeId, List replacements)? onErase; - final PenTool tool; - final Color color; - final double strokeWidth; - final bool filled; - final InteractionMode interactionMode; - final int rotation; - - const PdfAnnotationLayer({ - super.key, - required this.strokes, - this.onStrokeComplete, - this.onErase, - this.tool = PenTool.pen, - this.color = Colors.black, - this.strokeWidth = 2.0, - this.filled = false, - this.interactionMode = InteractionMode.draw, - this.rotation = 0, - }); - - @override - State createState() => _PdfAnnotationLayerState(); -} - -class _PdfAnnotationLayerState extends State { - Size _canvasSize = Size.zero; - - /// Applies inverse rotation to normalized coordinates for rendering. - /// Converts from stored (possibly rotated) coords back to display coords. - Offset _inverseRotate(double nx, double ny, int rotation) { - switch (rotation % 360) { - case 90: - return Offset(1.0 - ny, nx); - case 180: - return Offset(1.0 - nx, 1.0 - ny); - case 270: - return Offset(ny, 1.0 - nx); - default: - return Offset(nx, ny); - } - } - - /// Applies forward rotation to normalized coordinates before storage. - /// Converts from display coords to the canonical rotated representation. - Offset _forwardRotate(double nx, double ny, int rotation) { - switch (rotation % 360) { - case 90: - return Offset(ny, 1.0 - nx); - case 180: - return Offset(1.0 - nx, 1.0 - ny); - case 270: - return Offset(1.0 - ny, nx); - default: - return Offset(nx, ny); - } - } - - /// Scales a stroke's points from normalized [0, 1] coordinates to - /// the current canvas pixel coordinates for rendering. - /// Applies inverse rotation before scaling so strokes render correctly - /// on a rotated page. - List get _scaledStrokes { - if (_canvasSize == Size.zero) return widget.strokes; - return widget.strokes.map((stroke) { - return InkStroke( - id: stroke.id, - points: stroke.points.map((pt) { - final rotated = _inverseRotate(pt.x, pt.y, widget.rotation); - return InkPoint( - x: rotated.dx * _canvasSize.width, - y: rotated.dy * _canvasSize.height, - pressure: pt.pressure, - tilt: pt.tilt, - timestamp: pt.timestamp, - pointerDeviceKind: pt.pointerDeviceKind, - ); - }).toList(), - tool: stroke.tool, - color: stroke.color, - strokeWidth: stroke.strokeWidth, - createdAt: stroke.createdAt, - filled: stroke.filled, - textContent: stroke.textContent, - fontSize: stroke.fontSize, - ); - }).toList(); - } - - /// Normalizes a stroke's points from canvas pixel coordinates to - /// [0, 1] relative to the overlay size. - /// Applies forward rotation before storage so the canonical representation - /// accounts for the current page rotation. - InkStroke _normalizeStroke(InkStroke stroke) { - if (_canvasSize == Size.zero) return stroke; - return InkStroke( - id: stroke.id, - points: stroke.points.map((pt) { - final nx = pt.x / _canvasSize.width; - final ny = pt.y / _canvasSize.height; - final rotated = _forwardRotate(nx, ny, widget.rotation); - return InkPoint( - x: rotated.dx, - y: rotated.dy, - pressure: pt.pressure, - tilt: pt.tilt, - timestamp: pt.timestamp, - pointerDeviceKind: pt.pointerDeviceKind, - ); - }).toList(), - tool: stroke.tool, - color: stroke.color, - strokeWidth: stroke.strokeWidth, - createdAt: stroke.createdAt, - filled: stroke.filled, - textContent: stroke.textContent, - fontSize: stroke.fontSize, - ); - } - - void _onStrokeComplete(InkStroke stroke) { - widget.onStrokeComplete?.call(_normalizeStroke(stroke)); - } - - void _onErase(String strokeId, List replacements) { - widget.onErase?.call(strokeId, replacements.map(_normalizeStroke).toList()); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - _canvasSize = Size(constraints.maxWidth, constraints.maxHeight); - return InkCanvas( - strokes: _scaledStrokes, - onStrokeComplete: _onStrokeComplete, - onErase: _onErase, - tool: widget.tool, - color: widget.color, - strokeWidth: widget.strokeWidth, - filled: widget.filled, - interactionMode: widget.interactionMode, - ); - }, - ); - } -}