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 '../editor/pdf/spike_launcher.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())); }, ), // New pen-first canvas editor (beta). IconButton( icon: const Icon(Icons.draw_outlined), tooltip: 'Pen Canvas (beta)', onPressed: () => openM1Spike(context), ), ], ), 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 _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 _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 _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( 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 _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( 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, ), ); } } }