Files
BadNote/lib/screens/search_screen.dart
Akiba So dfe5f2a477
Some checks failed
CI / Windows build (push) Has been cancelled
feat(note): rebuild note editor on the pen-first canvas
Notes now use the single performant inking engine (PenCanvas) instead of
the old ink_canvas, per "all note features on the pen-first canvas".

- ink_stroke_adapter: pure InkStroke<->PenStroke bridge (normalize against
  a logical note page; drop non-freehand shapes/text). Round-trip tested.
- pen_palette_widgets: shared M3 ToolButton/PaletteDivider/RoundIconButton
  so PDF + note editors use identical chrome (PenEditorScreen migrated to
  them; its private copies deleted).
- PenNoteScreen: PenCanvas over a white logical page, undo/redo, title,
  save -> Note.strokes (+ local OCR for search). Pressure curve, eraser
  size/mode and palm rejection all inherited from the shared canvas.
- Route home (new/open) + search note hits -> PenNoteScreen; remove the
  now-redundant "Pen Canvas (beta)" spike button; delete the dead old
  note_editor_screen.

Tests: ink_stroke_adapter (5) + pen_note_screen widget (load + commit, 2).
flutter analyze: 0 issues. Full suite: 265/265.
2026-06-23 10:21:40 +08:00

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 '../editor/canvas/pen_note_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: (_) => 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 = <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,
),
);
}
}