Compare commits

...

2 Commits

Author SHA1 Message Date
3507e929b1 refactor: delete dead old PDF annotator screen
All checks were successful
CI / Windows build (push) Successful in 11m52s
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.
2026-06-23 10:06:02 +08:00
96594fbe1b feat(route): search opens PDFs in pen editor too
The search-result document jump still opened the OLD PdfAnnotatorScreen,
the last live entry to it. Route it to PenEditorScreen instead, and add
an initialPage param to the editor so the jump lands on the hit's page
(clamped to the document range once it loads).

With this, PenEditorScreen is the ONLY reachable PDF surface; the old
annotator is now dead code (no remaining references).

flutter analyze: 0 issues. Full suite: 258/258.
2026-06-23 10:04:38 +08:00
5 changed files with 18 additions and 1353 deletions

View File

@@ -41,10 +41,18 @@ String _documentIdFromPath(String path) {
}
class PenEditorScreen extends StatefulWidget {
const PenEditorScreen({super.key, required this.pdfPath});
const PenEditorScreen({
super.key,
required this.pdfPath,
this.initialPage = 0,
});
final String pdfPath;
/// 0-based page to open on (e.g. a search-result jump). Clamped to the
/// document's page range once it loads.
final int initialPage;
@override
State<PenEditorScreen> createState() => _PenEditorScreenState();
}
@@ -212,7 +220,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
doc.dispose();
return;
}
setState(() => _document = doc);
setState(() {
_document = doc;
// Honor a requested initial page (search-result jump), clamped.
if (doc.pages.isNotEmpty) {
_pageIndex = widget.initialPage.clamp(0, doc.pages.length - 1);
}
});
} catch (e) {
if (mounted) setState(() => _openError = e);
}

View File

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

View File

@@ -3,11 +3,11 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/search_provider.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@@ -222,7 +222,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
PdfAnnotatorScreen(filePath: filePath, initialPage: pageNumber),
PenEditorScreen(pdfPath: filePath, initialPage: pageNumber),
),
);
},

View File

@@ -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<int> onPageTap;
final Set<int> 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<PageThumbnailSidebar> createState() => _PageThumbnailSidebarState();
}
class _PageThumbnailSidebarState extends State<PageThumbnailSidebar> {
/// Cached thumbnail image data keyed by page index.
final Map<int, ImageProvider> _cache = {};
/// Pages currently being generated (to avoid duplicate work).
final Set<int> _loading = {};
/// Pages that permanently failed thumbnail generation (null result or throw).
/// Skipped on subsequent rebuilds to avoid a retry storm.
final Set<int> _failed = {};
@override
void didUpdateWidget(PageThumbnailSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.documentId != widget.documentId) {
_cache.clear();
_loading.clear();
_failed.clear();
}
}
Future<void> _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,
),
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -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<InkStroke> strokes;
final void Function(InkStroke stroke)? onStrokeComplete;
final void Function(String strokeId, List<InkStroke> 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<PdfAnnotationLayer> createState() => _PdfAnnotationLayerState();
}
class _PdfAnnotationLayerState extends State<PdfAnnotationLayer> {
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<InkStroke> 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<InkStroke> 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,
);
},
);
}
}