Files
BadNote/lib/screens/search_screen.dart
Akiba So a7d71e7cfd
Some checks failed
CI / Windows build (push) Has been cancelled
feat(i18n): localize settings + search screens
Extend the en/zh localization to the two screens reached from the home
app bar (the "全都用 + 多语言" ask):
- settings: title, theme-mode segments (System/Light/Dark), seed-color
  description, color-picker + clear-data dialogs.
- search: hint, error, empty/no-results states, Notes/Documents section
  headers, page label.

New ARB keys regenerated; l10n_test now asserts the new keys resolve in
both English and Chinese.

flutter analyze: 0 issues. l10n_test: 3/3.
2026-06-23 10:03:02 +08:00

289 lines
8.3 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/search_provider.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_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: (_) =>
PdfAnnotatorScreen(filePath: 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,
),
);
}
}