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>
744 lines
24 KiB
Dart
744 lines
24 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|