import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../editor/canvas/pen_editor_screen.dart'; import '../l10n/app_localizations.dart'; import '../models/note.dart'; import '../providers/search_provider.dart'; import '../editor/canvas/pen_note_screen.dart'; class SearchScreen extends ConsumerStatefulWidget { const SearchScreen({super.key, this.embeddedInShell = false}); final bool embeddedInShell; @override ConsumerState createState() => _SearchScreenState(); } class _SearchScreenState extends ConsumerState { 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); final l = AppLocalizations.of(context); return Scaffold( appBar: AppBar( title: TextField( controller: _controller, autofocus: true, decoration: InputDecoration( hintText: l.searchHint, border: InputBorder.none, hintStyle: const 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(l.searchError('$e'))), data: (hits) { final query = ref.watch(searchQueryProvider); if (query.isEmpty) { return Center( child: Text( l.typeToSearch, style: const 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( l.noResultsFor(query), style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ); } final noteHits = hits.whereType().toList(); final docHits = hits.whereType().toList(); return ListView( children: [ if (noteHits.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( l.sectionNotes, 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( l.sectionDocuments, 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: (_) => PenNoteScreen(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( AppLocalizations.of(context).pageLabel(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: (_) => PenEditorScreen(pdfPath: 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 = []; 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, ), ); } }