Fix bugs across app + server, optimize UI/UX, add Gitea CI
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:
743
lib/screens/home_screen.dart
Normal file
743
lib/screens/home_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
303
lib/screens/note_editor_screen.dart
Normal file
303
lib/screens/note_editor_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
981
lib/screens/pdf_annotator_screen.dart
Normal file
981
lib/screens/pdf_annotator_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
141
lib/screens/pdf_text_search.dart
Normal file
141
lib/screens/pdf_text_search.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
528
lib/screens/ppt_annotator_screen.dart
Normal file
528
lib/screens/ppt_annotator_screen.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
286
lib/screens/search_screen.dart
Normal file
286
lib/screens/search_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
344
lib/screens/settings_screen.dart
Normal file
344
lib/screens/settings_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
469
lib/screens/split_view_screen.dart
Normal file
469
lib/screens/split_view_screen.dart
Normal 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});
|
||||
}
|
||||
Reference in New Issue
Block a user