The search-result document jump still opened the OLD PdfAnnotatorScreen, the last live entry to it. Route it to PenEditorScreen instead, and add an initialPage param to the editor so the jump lands on the hit's page (clamped to the document range once it loads). With this, PenEditorScreen is the ONLY reachable PDF surface; the old annotator is now dead code (no remaining references). flutter analyze: 0 issues. Full suite: 258/258.
289 lines
8.3 KiB
Dart
289 lines
8.3 KiB
Dart
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 'note_editor_screen.dart';
|
|
|
|
class SearchScreen extends ConsumerStatefulWidget {
|
|
const SearchScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
|
}
|
|
|
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|
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<NoteSearchHit>().toList();
|
|
final docHits = hits.whereType<DocumentSearchHit>().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: (_) => NoteEditorScreen(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 = <TextSpan>[];
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|