Fix bugs across app + server, optimize UI/UX, add Gitea CI
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled

Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 03:18:00 +08:00
commit 72428dc075
210 changed files with 18171 additions and 0 deletions

View File

@@ -0,0 +1,743 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart';
import '../models/note.dart';
import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
import 'ppt_annotator_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
import 'split_view_screen.dart';
// [M1] Relative date helper — no new package dependencies.
String _formatDate(DateTime d) {
final now = DateTime.now();
final diff = now.difference(d);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
return 'Yesterday';
}
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
}
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(noteListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('BadNote'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Import PDF',
onPressed: () => _importPdf(context),
),
IconButton(
icon: const Icon(Icons.slideshow),
tooltip: 'Import PPT',
onPressed: () => _importPptx(context),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _createAndOpenNote(context, ref),
child: const Icon(Icons.add),
),
body: notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (notes) {
final documentsAsync = ref.watch(documentListProvider);
final documents = documentsAsync.valueOrNull ?? [];
if (notes.isEmpty && documents.isEmpty) {
return _buildEmptyState(context, ref);
}
return RefreshIndicator(
onRefresh: () async {
await Future.wait([
ref.read(noteListProvider.notifier).loadNotes(),
ref.read(documentListProvider.notifier).loadDocuments(),
]);
},
child: CustomScrollView(
slivers: [
// Notes section header always shown when documents exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (notes.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _NoteTile(note: notes[index]),
childCount: notes.length,
),
)
else
// [M2] Per-section empty hint when documents exist but notes don't
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Center(
child: Text(
'No ink notes yet — tap + to create one',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
// Documents section header always shown when notes exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Recent Documents',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (documents.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_DocumentTile(document: documents[index]),
childCount: documents.length,
),
)
else
// [M2] Per-section empty hint when notes exist but documents don't
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Center(
child: Text(
'No documents yet — import a PDF or PPT',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 80)),
],
),
);
},
),
);
}
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final note = await ref.read(noteListProvider.notifier).createNote();
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
}
}
Future<void> _importPdf(BuildContext context) async {
final pdfService = PdfService();
final filePath = await pdfService.pickPdfFile();
if (filePath != null && context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
),
);
}
}
Future<void> _importPptx(BuildContext context) async {
final pptxService = PptxService();
final filePath = await pptxService.openPptxFile();
if (filePath == null || !context.mounted) return;
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_note,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'No notes yet',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Create your first note',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () => _createAndOpenNote(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Note'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPdf(context),
icon: const Icon(Icons.picture_as_pdf),
label: const Text('Import PDF'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPptx(context),
icon: const Icon(Icons.slideshow),
label: const Text('Import PPT'),
),
],
),
);
}
}
class _NoteTile extends ConsumerStatefulWidget {
final Note note;
const _NoteTile({required this.note});
@override
ConsumerState<_NoteTile> createState() => _NoteTileState();
}
class _NoteTileState extends ConsumerState<_NoteTile> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final note = widget.note;
// [M1] Use relative date helper
final dateStr = _formatDate(note.updatedAt);
final ocrStatusMap = ref.watch(ocrStatusProvider);
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
// [H2] Right-click context menu via GestureDetector + MouseRegion for hover
return GestureDetector(
onSecondaryTapDown: (details) =>
_showContextMenu(context, details.globalPosition),
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: ListTile(
title: Row(
children: [
Expanded(
child: Text(
note.title.isEmpty ? 'Untitled' : note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_OcrStatusBadge(status: ocrStatus),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
if (note.tags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
children: note.tags
.map(
(t) => Chip(
label: Text(
t,
style: const TextStyle(fontSize: 11),
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
)
.toList(),
),
),
],
),
),
// [H2] Trailing delete button — always visible with subdued color, brighter on hover
trailing: IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Delete note',
onPressed: () => _confirmDelete(context),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
);
},
onLongPress: () => _confirmDelete(context),
),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final result = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx + 1,
position.dy + 1,
),
items: [
PopupMenuItem(
value: 'open',
child: Row(
children: const [
Icon(Icons.edit_outlined),
SizedBox(width: 8),
Text('Open'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'Delete',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
),
],
);
if (!mounted) return;
if (result == 'open') {
Navigator.of(this.context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
);
} else if (result == 'delete') {
_confirmDelete(this.context);
}
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete note?'),
content: Text(
'Delete "${widget.note.title.isEmpty ? 'Untitled' : widget.note.title}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(noteListProvider.notifier).deleteNote(widget.note.id);
Navigator.pop(ctx);
},
child: const Text('Delete'),
),
],
),
);
}
}
class _DocumentTile extends ConsumerStatefulWidget {
final Document document;
const _DocumentTile({required this.document});
@override
ConsumerState<_DocumentTile> createState() => _DocumentTileState();
}
class _DocumentTileState extends ConsumerState<_DocumentTile> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final document = widget.document;
// [M1] Use relative date helper
final dateStr = _formatDate(document.updatedAt);
final isPdf = document.docType == 'pdf';
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
// [H2] Right-click context menu + MouseRegion + trailing action buttons
return GestureDetector(
onSecondaryTapDown: (details) =>
_showContextMenu(context, details.globalPosition),
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: ListTile(
leading: Icon(
isPdf ? Icons.picture_as_pdf : Icons.slideshow,
color: isPdf ? Colors.red : Colors.orange,
),
title: Text(
document.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
// [H2] Trailing row: split-view (PDF only) + remove
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
IconButton(
icon: Icon(
Icons.vertical_split,
color: _hovering
? Theme.of(context).colorScheme.primary
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Open in Split View',
onPressed: () => _openSplitView(context),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Remove document',
onPressed: () => _confirmDelete(context),
),
],
),
// [L2] Routing bug fix: route by docType
onTap: () => _openDocument(context),
onLongPress: () => _showDocumentMenu(context),
),
),
);
}
// [L2] Route by docType: pdf → PdfAnnotatorScreen, ppt/pptx → PptAnnotatorScreen
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final isPdf = document.docType == 'pdf';
if (isPdf) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: document.filePath),
),
);
} else {
// PPT/PPTX: convert to images then push PptAnnotatorScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Processing presentation...')),
);
}
final pptxService = PptxService();
final slideImages = await pptxService.convertToImages(document.filePath);
final extractedText = await pptxService.extractText(document.filePath);
if (!mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Could not open presentation.')),
);
return;
}
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
filePath: document.filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
void _openSplitView(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.document.filePath,
documentId: widget.document.id,
),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final isPdf = widget.document.docType == 'pdf';
final result = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx + 1,
position.dy + 1,
),
items: [
PopupMenuItem(
value: 'open',
child: Row(
children: const [
Icon(Icons.open_in_new),
SizedBox(width: 8),
Text('Open'),
],
),
),
if (isPdf)
PopupMenuItem(
value: 'split',
child: Row(
children: const [
Icon(Icons.vertical_split),
SizedBox(width: 8),
Text('Open in Split View'),
],
),
),
PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'Remove',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
),
],
);
if (!mounted) return;
if (result == 'open') {
_openDocument(this.context);
} else if (result == 'split') {
_openSplitView(this.context);
} else if (result == 'remove') {
_confirmDelete(this.context);
}
}
void _showDocumentMenu(BuildContext context) {
final isPdf = widget.document.docType == 'pdf';
showModalBottomSheet(
context: context,
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
ListTile(
leading: const Icon(Icons.vertical_split),
title: const Text('Open in Split View'),
subtitle: const Text('PDF reference + scratchpad'),
onTap: () {
Navigator.of(ctx).pop();
_openSplitView(context);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text(
'Remove document',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.of(ctx).pop();
_confirmDelete(context);
},
),
],
),
);
},
);
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove document?'),
content: Text(
'Remove "${widget.document.filename}" from recent documents?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(documentListProvider.notifier)
.removeDocument(widget.document.id);
Navigator.pop(ctx);
},
child: const Text('Remove'),
),
],
),
);
}
}
// [M3] OCR status badge with semantic theme colors and tooltips
class _OcrStatusBadge extends StatelessWidget {
final OcrStatus status;
const _OcrStatusBadge({required this.status});
@override
Widget build(BuildContext context) {
switch (status) {
case OcrStatus.none:
return const SizedBox.shrink();
case OcrStatus.processing:
return Tooltip(
message: 'Processing OCR…',
child: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
);
case OcrStatus.done:
return Tooltip(
message: 'OCR complete',
child: Icon(
Icons.check_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
case OcrStatus.failed:
return Tooltip(
message: 'OCR failed',
child: Icon(
Icons.error_outline,
size: 16,
color: Theme.of(context).colorScheme.error,
),
);
}
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
class NoteEditorScreen extends ConsumerStatefulWidget {
final Note? note;
const NoteEditorScreen({super.key, this.note});
@override
ConsumerState<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
final UndoManager _undoManager = UndoManager();
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
String _title = 'Untitled';
final TextEditingController _titleController = TextEditingController();
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
final TransformationController _zoomController = TransformationController();
double _zoomLevel = 1.0;
bool _isDirty = false;
Note? get _existingNote => widget.note;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
@override
void initState() {
super.initState();
if (_existingNote != null) {
_title = _existingNote!.title;
for (final stroke in _existingNote!.strokes) {
_undoManager.addStroke(stroke);
}
}
_titleController.text = _title;
}
@override
void dispose() {
_titleController.dispose();
_zoomController.dispose();
super.dispose();
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_undoManager.addStroke(stroke);
_isDirty = true;
});
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _undoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_isDirty = true;
}
});
}
void _undo() {
setState(() {
_undoManager.undo();
_isDirty = true;
});
}
void _redo() {
setState(() {
_undoManager.redo();
_isDirty = true;
});
}
Future<void> _save() async {
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
Note savedNote;
if (_existingNote != null) {
final updated = _existingNote!.copyWith(
title: _title,
strokes: _undoManager.currentStrokes.toList(),
updatedAt: now,
);
await notifier.updateNote(updated);
savedNote = updated;
} else {
final note = await notifier.createNote(title: _title);
final updated = note.copyWith(
strokes: _undoManager.currentStrokes.toList(),
);
await notifier.updateNote(updated);
savedNote = updated;
}
if (!mounted) return;
setState(() {
_isDirty = false;
});
_runLocalOcr(savedNote);
}
/// Run local OCR and index results for search.
void _runLocalOcr(Note note) {
final noteId = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.processing,
};
ref
.read(ocrServiceProvider)
.processNote(note)
.then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.done,
};
})
.catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.failed,
};
});
}
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomReset() {
_applyZoom(1.0);
}
void _applyZoom(double level) {
setState(() => _zoomLevel = level);
_zoomController.value = Matrix4.diagonal3Values(level, level, 1.0);
}
Future<void> _saveAndNotify() async {
await _save();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Saved')));
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _isDirty) _save();
},
child: 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,
SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveAndNotify,
},
child: Focus(
autofocus: true,
child: Scaffold(
appBar: AppBar(
title: SizedBox(
height: 40,
child: TextField(
controller: _titleController,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Note title...',
contentPadding: const EdgeInsets.symmetric(vertical: 8),
suffix: _isDirty
? const Text(
'',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
)
: null,
),
onChanged: (value) {
_title = value;
setState(() => _isDirty = true);
},
),
),
actions: [
IconButton(
icon: const Icon(Icons.check),
tooltip: 'Save',
onPressed: _saveAndNotify,
),
],
),
body: 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,
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomReset,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: InteractiveViewer(
transformationController: _zoomController,
minScale: 0.5,
maxScale: 5.0,
child: InkCanvas(
strokes: _undoManager.currentStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,981 @@
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

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
/// A dialog for searching text within a PDF using SfPdfViewer's built-in search.
class PdfTextSearchDialog extends StatefulWidget {
final PdfViewerController viewerController;
const PdfTextSearchDialog({super.key, required this.viewerController});
@override
State<PdfTextSearchDialog> createState() => _PdfTextSearchDialogState();
}
class _PdfTextSearchDialogState extends State<PdfTextSearchDialog> {
final TextEditingController _queryController = TextEditingController();
PdfTextSearchResult? _searchResult;
String _statusText = '';
@override
void dispose() {
_queryController.dispose();
_searchResult?.clear();
super.dispose();
}
void _search() {
final query = _queryController.text.trim();
if (query.isEmpty) return;
final result = widget.viewerController.searchText(query);
setState(() {
_searchResult = result;
_updateStatus();
});
}
void _nextMatch() {
_searchResult?.nextInstance();
_updateStatus();
}
void _previousMatch() {
_searchResult?.previousInstance();
_updateStatus();
}
void _updateStatus() {
final result = _searchResult;
if (result == null || result.totalInstanceCount == 0) {
setState(() => _statusText = 'No matches');
} else {
setState(() {
_statusText =
'${result.currentInstanceIndex} of ${result.totalInstanceCount} matches';
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _queryController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search in PDF...',
prefixIcon: Icon(Icons.search),
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _search(),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: _search,
),
],
),
const SizedBox(height: 12),
Row(
children: [
Text(
_statusText,
style: Theme.of(context).textTheme.bodySmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.keyboard_arrow_up),
tooltip: 'Previous match',
onPressed:
_searchResult != null &&
_searchResult!.totalInstanceCount > 0
? _previousMatch
: null,
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
tooltip: 'Next match',
onPressed:
_searchResult != null &&
_searchResult!.totalInstanceCount > 0
? _nextMatch
: null,
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Clear search',
onPressed: () {
_searchResult?.clear();
setState(() {
_queryController.clear();
_searchResult = null;
_statusText = '';
});
},
iconSize: 20,
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,528 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Per-slide annotation state. The [UndoManager] is the single source of
/// truth for a slide's strokes; [strokes] reflects its current contents so
/// the live canvas and the PDF export always render what was actually drawn.
class _SlideAnnotations {
final UndoManager undoManager = UndoManager();
List<InkStroke> get strokes => undoManager.currentStrokes;
}
/// Screen that displays PPTX slides with an ink annotation overlay.
///
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
/// sits on top of each slide so the user can annotate freely. Annotations are
/// stored per-slide and can be exported as a PDF.
class PptAnnotatorScreen extends StatefulWidget {
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
const PptAnnotatorScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
@override
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
}
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
late final PageController _pageController;
late final Map<int, _SlideAnnotations> _annotations;
int _currentPage = 0;
bool _isDrawing = false;
bool _showTextPanel = false;
// Set to true once the unsaved-annotations warning SnackBar has been shown.
bool _hasShownUnsavedWarning = false;
// Toolbar state
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
// Derived
late final String _fileName;
late final int _slideCount;
late final String _extractedText;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
UndoManager get _currentUndoManager =>
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
@override
void initState() {
super.initState();
_fileName = p.basename(widget.filePath);
_slideCount = widget.slideImagePaths.length;
_extractedText = widget.extractedText ?? '';
_pageController = PageController();
_annotations = {};
for (var i = 0; i < _slideCount; i++) {
_annotations[i] = _SlideAnnotations();
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
// -- Drawing callbacks --
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_currentUndoManager.addStroke(stroke);
});
// Warn once per session that PPT annotations are not auto-saved.
if (!_hasShownUnsavedWarning) {
_hasShownUnsavedWarning = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
),
duration: Duration(seconds: 5),
),
);
});
}
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _currentUndoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_currentUndoManager.removeStroke(original, replacements: replacements);
}
});
}
// -- Export --
Future<void> _exportPdf() async {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
if (!mounted) return;
final dir = await _getExportDir();
final baseName = p.basenameWithoutExtension(_fileName);
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _getExportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
// Draw slide image
final imgPath = widget.slideImagePaths[i];
try {
final imgBytes = await File(imgPath).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imgW = bitmap.width.toDouble();
final imgH = bitmap.height.toDouble();
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
final drawW = imgW * scale;
final drawH = imgH * scale;
final offX = (pageSize.width - drawW) / 2;
final offY = (pageSize.height - drawH) / 2;
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
page.graphics.drawImage(bitmap, imgRect);
// Draw ink strokes
final annots = _annotations[i];
if (annots != null && annots.strokes.isNotEmpty) {
// KNOWN LIMITATION: strokes are captured in the live viewer's
// full-fill pixel space (the InkCanvas is Positioned.fill over the
// whole slide area, while the slide image is BoxFit.contain inside
// it). The scale below is derived from the PDF page layout, not the
// live widget size, so exported ink can be misaligned/scaled wrong.
// A correct fix normalizes strokes to [0,1] of the *rendered image
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
// to the PDF draw rect here. Requires on-device visual verification.
final imgAspect = imgW / imgH;
final pageAspect = pageSize.width / pageSize.height;
double widgetW, widgetH;
if (imgAspect > pageAspect) {
widgetW = pageSize.width;
widgetH = pageSize.width / imgAspect;
} else {
widgetH = pageSize.height;
widgetW = pageSize.height * imgAspect;
}
final scaleX = drawW / widgetW;
final scaleY = drawH / widgetH;
for (final stroke in annots.strokes) {
if (stroke.tool == PenTool.eraser) continue;
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final pdfColor = PdfColor(r, g, b);
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final pt1 = stroke.points[j];
final pt2 = stroke.points[j + 1];
path.addLine(
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
);
}
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// -- UI --
@override
Widget build(BuildContext context) {
// No slides to annotate: show an empty state and skip the toolbar, which
// would otherwise dereference a non-existent slide's annotation state.
if (_slideCount == 0) {
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
),
body: const Center(child: Text('No slides to display')),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (_extractedText.isNotEmpty)
IconButton(
icon: Icon(
_showTextPanel
? Icons.text_snippet
: Icons.text_snippet_outlined,
),
tooltip: 'Toggle extracted text',
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Export as PDF',
onPressed: _exportPdf,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _currentUndoManager.canUndo,
canRedo: _currentUndoManager.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,
),
Expanded(
child: Row(
children: [
Expanded(child: _buildSlideViewer()),
if (_showTextPanel) _buildTextPanel(),
],
),
),
_buildPageIndicator(),
],
),
);
}
Widget _buildSlideViewer() {
if (_slideCount == 0) {
return const Center(child: Text('No slides to display'));
}
return Listener(
onPointerDown: (_) => setState(() => _isDrawing = true),
onPointerUp: (_) => setState(() => _isDrawing = false),
child: PageView.builder(
controller: _pageController,
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
itemCount: _slideCount,
onPageChanged: (page) => setState(() => _currentPage = page),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: Stack(
children: [
// Slide image (background)
Positioned.fill(
child: Image.file(
File(widget.slideImagePaths[index]),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey.shade200,
child: Center(
child: Text(
'Slide ${index + 1}',
style: TextStyle(
fontSize: 24,
color: Colors.grey.shade500,
),
),
),
),
),
),
// Ink annotation overlay (foreground)
Positioned.fill(
child: InkCanvas(
strokes: _annotations[index]?.strokes ?? [],
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
],
),
);
},
),
);
}
Widget _buildPageIndicator() {
if (_slideCount == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Theme.of(context).colorScheme.surface,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Previous button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0
? () => _pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
// Dot row for small decks; compact text counter for large decks.
if (_slideCount <= 12)
...List.generate(_slideCount, (i) {
final isActive = i == _currentPage;
return GestureDetector(
onTap: () => _pageController.animateToPage(
i,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Container(
width: isActive ? 12 : 8,
height: isActive ? 12 : 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
);
})
else
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
// Next button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < _slideCount - 1
? () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
const Spacer(),
// Slide counter is always shown at the trailing end for dot mode;
// the compact text above already serves this role for large decks.
if (_slideCount <= 12)
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildTextPanel() {
return SizedBox(
width: 280,
child: Card(
margin: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Row(
children: [
const Icon(Icons.text_fields, size: 18),
const SizedBox(width: 8),
Text(
'Extracted Text',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() => _showTextPanel = false),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: SelectableText(
_extractedText,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
// -- Dialogs --
void _undo() {
setState(() => _currentUndoManager.undo());
}
void _redo() {
setState(() => _currentUndoManager.redo());
}
}

View File

@@ -0,0 +1,286 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final TextEditingController _controller = TextEditingController();
Timer? _debounce;
@override
void initState() {
super.initState();
_controller.addListener(() => setState(() {}));
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
void _onQueryChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
ref.read(searchQueryProvider.notifier).state = value.trim();
});
}
@override
Widget build(BuildContext context) {
final results = ref.watch(searchResultsProvider);
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search notes and documents...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey),
),
onChanged: _onQueryChanged,
onSubmitted: (value) {
_debounce?.cancel();
ref.read(searchQueryProvider.notifier).state = value.trim();
},
),
actions: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
ref.read(searchQueryProvider.notifier).state = '';
},
),
],
),
body: results.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Search error: $e')),
data: (hits) {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) {
return const Center(
child: Text(
'Type to search your notes and documents',
style: TextStyle(color: Colors.grey),
),
);
}
if (hits.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No results for "$query"',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
final noteHits = hits.whereType<NoteSearchHit>().toList();
final docHits = hits.whereType<DocumentSearchHit>().toList();
return ListView(
children: [
if (noteHits.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
...noteHits.map(
(hit) => _NoteSearchResultTile(note: hit.note, query: query),
),
],
if (docHits.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Documents',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
...docHits.map(
(hit) => _DocumentSearchResultTile(
documentId: hit.documentId,
filename: hit.filename,
filePath: hit.filePath,
pageNumber: hit.pageNumber,
snippet: hit.snippet,
query: query,
),
),
],
],
);
},
),
);
}
}
class _NoteSearchResultTile extends StatelessWidget {
final Note note;
final String query;
const _NoteSearchResultTile({required this.note, required this.query});
@override
Widget build(BuildContext context) {
final d = note.updatedAt;
final dateStr =
'${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
return ListTile(
leading: const Icon(Icons.edit_note),
title: _HighlightedText(text: note.title, query: query),
subtitle: Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
},
);
}
}
class _DocumentSearchResultTile extends StatelessWidget {
final String documentId;
final String filename;
final String filePath;
final int pageNumber;
final String snippet;
final String query;
const _DocumentSearchResultTile({
required this.documentId,
required this.filename,
required this.filePath,
required this.pageNumber,
required this.snippet,
required this.query,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.picture_as_pdf, color: Colors.red),
title: _HighlightedText(text: filename, query: query),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Page ${pageNumber + 1}',
style: Theme.of(context).textTheme.bodySmall,
),
if (snippet.isNotEmpty)
_HighlightedText(text: snippet, query: query, maxLines: 2),
],
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
PdfAnnotatorScreen(filePath: filePath, initialPage: pageNumber),
),
);
},
);
}
}
/// Highlights matching portions of [text] that match [query].
class _HighlightedText extends StatelessWidget {
final String text;
final String query;
final int maxLines;
const _HighlightedText({
required this.text,
required this.query,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
if (query.isEmpty || text.isEmpty) {
return Text(text, maxLines: maxLines, overflow: TextOverflow.ellipsis);
}
final lowerText = text.toLowerCase();
final lowerQuery = query.toLowerCase();
final spans = <TextSpan>[];
int start = 0;
while (true) {
final index = lowerText.indexOf(lowerQuery, start);
if (index < 0) {
if (start < text.length) {
spans.add(TextSpan(text: text.substring(start)));
}
break;
}
if (index > start) {
spans.add(TextSpan(text: text.substring(start, index)));
}
spans.add(
TextSpan(
text: text.substring(index, index + query.length),
style: TextStyle(
fontWeight: FontWeight.bold,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
),
),
);
start = index + query.length;
}
return RichText(
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: spans,
),
);
}
}

View File

@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/settings_provider.dart';
import '../utils/stroke_stabilizer.dart';
/// Material 3 settings screen for BadNote.
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
void _showColorPicker(
BuildContext context,
Color current,
ValueChanged<Color> onPicked,
) {
Color pickerColor = current;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Pick a color'),
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
onPicked(pickerColor);
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
void _confirmClearData(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear all local settings?'),
content: const Text(
'This will reset pen defaults and appearance settings. '
'Notes and documents are not affected.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(settingsProvider).clearAllData();
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings reset to defaults')),
);
},
child: const Text('Clear'),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
_SectionHeader(title: 'Defaults', icon: Icons.tune),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Default Tool',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PenTool>(
initialValue: settings.defaultTool,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: PenTool.values.map((tool) {
return DropdownMenuItem(
value: tool,
child: Text(tool.name),
);
}).toList(),
onChanged: (tool) {
if (tool != null) settings.setDefaultTool(tool);
},
),
const SizedBox(height: 16),
const Text(
'Default Color',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _showColorPicker(
context,
settings.defaultColor,
settings.setDefaultColor,
),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: settings.defaultColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outline),
),
),
),
),
const SizedBox(width: 12),
Text(
'#${settings.defaultColor.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: const TextStyle(fontFamily: 'monospace'),
),
],
),
const SizedBox(height: 16),
const Text(
'Default Stroke Width',
style: TextStyle(fontWeight: FontWeight.w500),
),
Slider(
value: settings.defaultStrokeWidth,
min: 1.0,
max: 20.0,
divisions: 19,
label: settings.defaultStrokeWidth.toStringAsFixed(1),
onChanged: settings.setDefaultStrokeWidth,
),
const SizedBox(height: 16),
const Text(
'Pressure Curve',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PressureCurveType>(
initialValue: settings.defaultPressureCurve,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: PressureCurveType.values.map((curve) {
return DropdownMenuItem(
value: curve,
child: Text(curve.name),
);
}).toList(),
onChanged: (curve) {
if (curve != null) {
settings.setDefaultPressureCurve(curve);
}
},
),
const SizedBox(height: 16),
const Text(
'Stabilization',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<StabilizationLevel>(
initialValue: settings.defaultStabilization,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: StabilizationLevel.values.map((level) {
return DropdownMenuItem(
value: level,
child: Text(level.name),
);
}).toList(),
onChanged: (level) {
if (level != null) settings.setDefaultStabilization(level);
},
),
],
),
),
const Divider(),
_SectionHeader(title: 'Appearance', icon: Icons.palette),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Theme Mode',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
),
],
selected: {settings.themeMode},
onSelectionChanged: (modes) {
settings.setThemeMode(modes.first);
},
),
const SizedBox(height: 16),
const Text(
'Color Scheme Seed',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _showColorPicker(
context,
settings.colorSchemeSeed,
settings.setColorSchemeSeed,
),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: settings.colorSchemeSeed,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outline),
),
),
),
),
const SizedBox(width: 12),
const Text('Seed color for Material 3 theme'),
],
),
],
),
),
const Divider(),
_SectionHeader(title: 'About', icon: Icons.info),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'BadNote v0.1.0',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
'Local-first Surface Pen note-taking with PDF/PPT annotation. '
'OCR and search run entirely on your device.',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _confirmClearData(context, ref),
icon: const Icon(Icons.delete_forever, color: Colors.red),
label: const Text(
'Clear All Local Settings',
style: TextStyle(color: Colors.red),
),
),
],
),
),
const SizedBox(height: 32),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final IconData icon;
const _SectionHeader({required this.title, required this.icon});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
}

View File

@@ -0,0 +1,469 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/database_service.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
class SplitViewScreen extends StatefulWidget {
final String filePath;
final String documentId;
const SplitViewScreen({
super.key,
required this.filePath,
required this.documentId,
});
@override
State<SplitViewScreen> createState() => _SplitViewState();
}
class _SplitViewState extends State<SplitViewScreen> {
// -- PDF (left pane) --
final PdfViewerController _pdfController = PdfViewerController();
int _currentPage = 0;
int _pageCount = 0;
String _fileName = '';
// -- Split divider --
double _leftPaneFraction = 0.5;
bool _isDraggingDivider = false;
// -- Scratchpad (right pane) --
final UndoManager _undoManager = UndoManager();
List<InkStroke> _strokes = [];
double _canvasWidth = 4000;
double _canvasHeight = 4000;
// -- Tool state --
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
// -- Auto-save debounce --
Timer? _saveTimer;
bool _dirty = false;
// -- Page link markers (optional feature) --
final List<_PageLink> _pageLinks = [];
static const double _edgeThreshold = 200.0;
static const double _expandAmount = 1000.0;
@override
void initState() {
super.initState();
_loadScratchpad();
}
@override
void dispose() {
_saveTimer?.cancel();
_saveImmediate();
_pdfController.dispose();
super.dispose();
}
// -- Persistence --
Future<void> _loadScratchpad() async {
final db = await DatabaseService.getInstance();
final strokes = await db.loadScratchpad(widget.documentId);
if (mounted) {
setState(() {
_strokes = strokes;
for (final s in strokes) {
_undoManager.addStroke(s);
}
});
}
}
void _scheduleSave() {
_dirty = true;
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(seconds: 3), _saveImmediate);
}
Future<void> _saveImmediate() async {
if (!_dirty) return;
_dirty = false;
final db = await DatabaseService.getInstance();
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
await db.saveScratchpad(widget.documentId, json);
}
// -- Scratchpad stroke callbacks --
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_strokes.add(stroke);
_undoManager.addStroke(stroke);
_checkCanvasExpansion(stroke);
});
_scheduleSave();
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_strokes = List.from(_undoManager.currentStrokes);
}
});
_scheduleSave();
}
void _undo() {
setState(() {
_undoManager.undo();
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
void _redo() {
setState(() {
_undoManager.redo();
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
// -- Auto-expand canvas --
void _checkCanvasExpansion(InkStroke stroke) {
double maxRight = 0;
double maxBottom = 0;
for (final p in stroke.points) {
if (p.x > maxRight) maxRight = p.x;
if (p.y > maxBottom) maxBottom = p.y;
}
bool expanded = false;
if (maxRight > _canvasWidth - _edgeThreshold) {
_canvasWidth += _expandAmount;
expanded = true;
}
if (maxBottom > _canvasHeight - _edgeThreshold) {
_canvasHeight += _expandAmount;
expanded = true;
}
if (expanded) setState(() {});
}
// -- Divider drag --
void _onDividerDragStart(DragStartDetails details) {
setState(() => _isDraggingDivider = true);
}
void _onDividerDragUpdate(
DragUpdateDetails details,
BoxConstraints constraints,
) {
final renderWidth = constraints.maxWidth;
if (renderWidth <= 0) return;
final delta = details.delta.dx / renderWidth;
setState(() {
_leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8);
});
}
void _onDividerDragEnd(DragEndDetails details) {
setState(() => _isDraggingDivider = false);
}
// -- PDF page navigation --
void _prevPage() {
if (_currentPage > 0) {
_pdfController.previousPage();
}
}
void _nextPage() {
if (_currentPage < _pageCount - 1) {
_pdfController.nextPage();
}
}
// -- Page link creation (long-press on left pane) --
void _onPdfLongPress(int pageNumber) {
// Place a page link marker at the current scratchpad viewport center.
// We approximate the viewport center as (0, 0) since InteractiveViewer
// manages its own transform — the user can reposition by panning.
setState(() {
_pageLinks.add(
_PageLink(
pageNumber: pageNumber,
position: const Offset(100, 100), // default top-left area
),
);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Page link marker added for page $pageNumber')),
);
}
void _onPageLinkTap(_PageLink link) {
_pdfController.jumpToPage(link.pageNumber);
setState(() {
_currentPage = link.pageNumber - 1;
});
}
void _deletePageLink(_PageLink link) {
setState(() {
_pageLinks.remove(link);
});
}
// -- Build --
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
_fileName.isEmpty ? 'Split View' : _fileName,
style: const TextStyle(fontSize: 16),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_saveImmediate();
Navigator.of(context).pop();
},
),
actions: [
// Left pane page navigation
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page (PDF)',
onPressed: _currentPage > 0 ? _prevPage : null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
'${_currentPage + 1} / $_pageCount',
style: const TextStyle(fontSize: 13),
),
),
),
IconButton(
icon: const Icon(Icons.navigate_next),
tooltip: 'Next page (PDF)',
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
),
const SizedBox(width: 8),
// Canvas info
Tooltip(
message:
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: Text(
'${_canvasWidth.round()}x${_canvasHeight.round()}',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
),
),
),
],
),
body: Column(
children: [
// Label clarifying that the toolbar controls the scratchpad pane.
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Scratchpad tools',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
// Toolbar (applies to scratchpad only)
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,
),
// Split view body
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final leftWidth = totalWidth * _leftPaneFraction;
final rightWidth =
totalWidth - leftWidth - 12; // 12px divider hit area
return Row(
children: [
// Left pane: PDF reference (read-only)
SizedBox(width: leftWidth, child: _buildPdfPane()),
// Draggable divider: 12px hit area, 4px visual strip.
GestureDetector(
onHorizontalDragStart: _onDividerDragStart,
onHorizontalDragUpdate: (d) =>
_onDividerDragUpdate(d, constraints),
onHorizontalDragEnd: _onDividerDragEnd,
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: SizedBox(
width: 12,
child: Center(
child: Container(
width: 4,
color: _isDraggingDivider
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
),
),
),
),
// Right pane: Infinite scratchpad
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
],
);
},
),
),
],
),
);
}
Widget _buildPdfPane() {
return Stack(
children: [
GestureDetector(
onLongPress: () {
// Long-press on PDF to create page link marker
_onPdfLongPress(_currentPage + 1);
},
child: SfPdfViewer.file(
File(widget.filePath),
controller: _pdfController,
canShowScrollHead: true,
canShowScrollStatus: true,
onPageChanged: (PdfPageChangedDetails details) {
setState(() {
_currentPage = details.newPageNumber - 1;
});
},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
setState(() {
_pageCount = details.document.pages.count;
_fileName = widget.filePath.split(Platform.pathSeparator).last;
});
},
),
),
// Page link markers overlay (on PDF pane, showing linked pages)
if (_pageLinks.isNotEmpty)
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
],
);
}
Widget _buildPageLinkChips() {
return Wrap(
spacing: 4,
runSpacing: 4,
children: _pageLinks.map((link) {
return GestureDetector(
onTap: () => _onPageLinkTap(link),
onLongPress: () => _deletePageLink(link),
child: Chip(
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
label: Text(
'p${link.pageNumber}',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
backgroundColor: Colors.blue.shade600,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
);
}).toList(),
);
}
Widget _buildScratchpadPane() {
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: InteractiveViewer(
constrained: false,
minScale: 0.25,
maxScale: 8.0,
boundaryMargin: const EdgeInsets.all(double.infinity),
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: InkCanvas(
strokes: _strokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: PressureCurve(type: _pressureCurveType),
stabilizationLevel: _stabilizationLevel,
filled: _filled,
interactionMode: InteractionMode.draw,
),
),
),
);
}
}
/// A marker linking a scratchpad position to a specific PDF page.
class _PageLink {
final int pageNumber;
final Offset position;
const _PageLink({required this.pageNumber, required this.position});
}