All checks were successful
CI / Windows build (push) Successful in 7m47s
Redesign the optional FastAPI companion around vault files (manifest / PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a client Server settings panel for health/login. Polish shell UX: l10n for settings/home/board, sticky-board empty state, and a narrow-screen diagnostics FAB. Co-authored-by: Cursor <cursoragent@cursor.com>
827 lines
27 KiB
Dart
827 lines
27 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 '../editor/canvas/office_document_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';
|
|
|
|
// Relative date helper — localized.
|
|
String _formatDate(BuildContext context, DateTime d) {
|
|
final l = AppLocalizations.of(context);
|
|
final now = DateTime.now();
|
|
final diff = now.difference(d);
|
|
if (diff.inSeconds < 60) return l.relativeJustNow;
|
|
if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes);
|
|
if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours);
|
|
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
|
|
return l.relativeYesterday;
|
|
}
|
|
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
class HomeScreen extends ConsumerWidget {
|
|
const HomeScreen({super.key, this.embeddedInShell = false});
|
|
|
|
/// When true, chrome (settings/search) is owned by [AppShell].
|
|
final bool embeddedInShell;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final notesAsync = ref.watch(noteListProvider);
|
|
final l = AppLocalizations.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(embeddedInShell ? l.libraryTab : l.appTitle),
|
|
centerTitle: !embeddedInShell,
|
|
actions: [
|
|
if (!embeddedInShell) ...[
|
|
IconButton(
|
|
icon: const Icon(Icons.settings),
|
|
tooltip: l.settings,
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.search),
|
|
tooltip: l.search,
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
IconButton(
|
|
icon: const Icon(Icons.file_open),
|
|
tooltip: l.importFile,
|
|
onPressed: () => _importFile(context, ref),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: () => _createAndOpenNote(context, ref),
|
|
icon: const Icon(Icons.add),
|
|
label: Text(l.createNotebook),
|
|
),
|
|
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: [
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
l.notesSection,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
|
child: Text(
|
|
l.documentsSection,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (documents.isNotEmpty)
|
|
// continue existing document list below — marker for patch
|
|
SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) =>
|
|
_DocumentTile(document: documents[index]),
|
|
childCount: documents.length,
|
|
),
|
|
)
|
|
else
|
|
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/docx → native [OfficeDocumentScreen];
|
|
/// legacy .ppt may still use image fallback.
|
|
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 'docx':
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => OfficeDocumentScreen(filePath: filePath),
|
|
),
|
|
);
|
|
case 'ppt':
|
|
// Legacy binary PPT — try native-ish image path for now.
|
|
await _openPresentation(context, filePath);
|
|
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(
|
|
AppLocalizations.of(context).emptyLibraryTitle,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
AppLocalizations.of(context).emptyLibraryBody,
|
|
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(context, 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(context, 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; pptx/docx → native OfficeDocumentScreen.
|
|
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' || document.docType == 'pptx') {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => OfficeDocumentScreen(filePath: document.filePath),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
{
|
|
// Legacy .ppt: 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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|