Files
BadNote/lib/screens/home_screen.dart
Akiba So e939759458
All checks were successful
CI / Windows build (push) Successful in 15m50s
feat(search): index PDF text, OCR scanned PDFs on import
Search now covers handwriting, the PDF text layer, AND scanned
(rasterized) PDFs.

- PdfTextIndexer runs at import: sums the embedded text layer across
  pages; if present it stores that as the document body, otherwise the
  PDF is rasterized and its rendered pages are OCR'd in the background.
  The result lands in the sidecar `pageText` field (distinct from
  `ocrText`, the handwriting OCR). Idempotent (skips a sidecar that
  already has pageText); degrades gracefully with no OCR engine.
- pdfrx_page_text_source abstracts text/render so it's testable.
- VaultSearchIndex now harvests title + typed text + handwriting OCR +
  PDF pageText, so search finds notes, typed PDFs and scanned PDFs.

analyze clean, 409 tests green.
2026-06-25 00:23:19 +08:00

845 lines
28 KiB
Dart

import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../l10n/app_localizations.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 '../providers/search_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../services/pptx_service.dart';
import '../services/vault_service.dart';
import '../editor/canvas/pen_note_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import 'search_screen.dart';
import 'settings_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);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(l.appTitle),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: l.settings,
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
IconButton(
icon: const Icon(Icons.file_open),
tooltip: l.importFile,
onPressed: () => _importFile(context, ref),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: l.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(
l.noNotesYetHint,
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(
l.noDocumentsYet,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 80)),
],
),
);
},
),
);
}
/// "Create notebook": prompt a title (defaulting to Untitled), create the
/// standalone notebook FOLDER + `notebook.badnote.json` via
/// `VaultService.createEmptyNotebook`, then open the editor on the new note.
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final title = await _promptNotebookTitle(context);
if (title == null) return; // cancelled
final l = context.mounted ? AppLocalizations.of(context) : null;
final resolved = title.trim().isEmpty
? (l?.untitledNote ?? 'Untitled')
: title.trim();
final note =
await ref.read(noteListProvider.notifier).createNote(title: resolved);
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
}
}
/// Ask for a notebook title. Returns the entered string (possibly empty →
/// caller defaults it), or null if the user cancelled.
Future<String?> _promptNotebookTitle(BuildContext context) {
final l = AppLocalizations.of(context);
final controller = TextEditingController(text: l.untitledNote);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.newNotebookTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: l.notebookTitleHint),
onSubmitted: (v) => Navigator.of(ctx).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: Text(l.create),
),
],
),
);
}
/// Single top-level "Import file" action (sibling of "Create notebook"):
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
/// open the IN-VAULT copy in the right editor (routed by extension).
Future<void> _importFile(BuildContext context, WidgetRef ref) async {
final l = AppLocalizations.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: VaultService.importableExtensions.toList(),
);
final picked = result?.files;
if (picked == null || picked.isEmpty) return;
final pickedPath = picked.first.path;
if (pickedPath == null) return;
final messenger = context.mounted ? ScaffoldMessenger.of(context) : null;
messenger?.showSnackBar(SnackBar(content: Text(l.processingImport)));
try {
final vault = await ref.read(vaultServiceProvider.future);
final vaultPath = await vault.createNotebook(pickedPath);
// Refresh the documents list so the new notebook shows on return.
await ref.read(documentListProvider.notifier).loadDocuments();
// For a PDF, index its document body (embedded text layer, or background
// OCR of a rasterized/scanned PDF) into the sidecar so search covers it.
// Fire-and-forget: import returns and opens the editor immediately.
_indexPdfInBackground(ref, vaultPath);
if (!context.mounted) return;
await _openVaultFile(context, ref, vaultPath);
} catch (e) {
messenger?.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
}
}
/// Kick off background document-body indexing for an in-vault PDF (no-op for
/// other types). Runs detached from the import await chain so the editor opens
/// immediately; on completion it bumps the search-index epoch so the newly
/// indexed text is searchable. Idempotency and graceful OCR degradation live in
/// [PdfTextIndexer]; failures here are swallowed (search just misses the body).
void _indexPdfInBackground(WidgetRef ref, String vaultPath) {
final ext = p.extension(vaultPath).replaceFirst('.', '').toLowerCase();
if (ext != 'pdf') return;
final indexer = ref.read(pdfTextIndexerProvider);
unawaited(() async {
final indexed = await indexer.indexPdf(vaultPath);
if (indexed != null && indexed.isNotEmpty) {
// Force the next search to re-scan the vault (picks up the new pageText).
final epoch = ref.read(searchIndexEpochProvider.notifier);
epoch.state = epoch.state + 1;
}
}());
}
/// Route an in-vault [filePath] to the correct editor by extension:
/// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to
/// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed
/// conversions surface a friendly message instead of crashing.
Future<void> _openVaultFile(
BuildContext context,
WidgetRef ref,
String filePath,
) async {
final l = AppLocalizations.of(context);
final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase();
switch (ext) {
case 'pdf':
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: filePath),
),
);
case 'pptx':
case 'ppt':
await _openPresentation(context, filePath);
case 'docx':
final pptxService = PptxService();
final pdfPath = await pptxService.convertToPdf(filePath);
if (!context.mounted) return;
if (pdfPath == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.convertNeedsLibreOffice)),
);
return;
}
// The converted PDF lives next to the docx in the notebook folder, so
// it becomes the annotatable artifact; re-scan picks it up.
await ref.read(documentListProvider.notifier).loadDocuments();
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: pdfPath),
),
);
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.unsupportedFileType(ext))),
);
}
}
Future<void> _openPresentation(
BuildContext context,
String filePath,
) async {
final l = AppLocalizations.of(context);
final pptxService = PptxService();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),
);
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (!context.mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
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: Text(AppLocalizations.of(context).createNotebook),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importFile(context, ref),
icon: const Icon(Icons.file_open),
label: Text(AppLocalizations.of(context).importFile),
),
],
),
);
}
}
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: [
// Stroke count is no longer cached in the vault scan (strokes
// load lazily in the editor), so the tile shows only the date.
Text(
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: (_) => PenNoteScreen(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: (_) => PenNoteScreen(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 remove button. Split view is now reached only by
// tapping a scratch-link anchor inside the PDF editor, so the
// standalone split-view entry was removed.
trailing: 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),
),
),
);
}
// Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen,
// docx → best-effort convert-to-PDF then open as PDF.
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final l = AppLocalizations.of(context);
if (document.docType == 'pdf') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
),
);
return;
}
if (document.docType == 'docx') {
final pdfPath = await PptxService().convertToPdf(document.filePath);
if (!mounted) return;
if (pdfPath == null) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(l.convertNeedsLibreOffice)),
);
return;
}
await ref.read(documentListProvider.notifier).loadDocuments();
if (!mounted) return;
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: pdfPath),
),
);
return;
}
{
// PPT/PPTX: convert to images then push PenSlideScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),
);
}
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(
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
filePath: document.filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
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.open_in_new),
SizedBox(width: 8),
Text('Open'),
],
),
),
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 == 'remove') {
_confirmDelete(this.context);
}
}
void _showDocumentMenu(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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,
),
);
}
}
}