feat(card): the card browser finds and sorts words of any language as typed

Search folded case and katakana only, for Japanese. It now also leaves out
accents, tone marks and umlauts (strasse finds Straße, maedchen and
madchen Mädchen, σπιτι σπίτι, xue xi xué xí), and A–Z sorts by the same
bare letters, so Ärger stands among the a's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 12:39:58 +02:00
commit 87bebd1953
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 84 additions and 4 deletions

View file

@ -126,18 +126,69 @@ String searchTextOf(Card card, List<Tag> tags) {
); );
} }
/// [text] as searches compare it: in lower case, katakana as hiragana, so /// [text] as searches compare it and A–Z orders it: in lower case,
/// テレビ is found by てれび and Tokyo by tokyo. /// katakana as hiragana, without accents and tone marks (Latin and Greek
String searchFolded(String text) => katakanaToHiragana(text.toLowerCase()); /// letters, pinyin's vowels), ß as ss and Greek's final sigma as σ; so
/// テレビ is found by てれび, Tokyo by tokyo, Straße by strasse, σπίτι by
/// σπιτι and 学习's xué xí by xue xi, and Ärger sorts among the a's.
String searchFolded(String text) {
final out = StringBuffer();
for (final rune in katakanaToHiragana(
text.toLowerCase().replaceAll('ß', 'ss').replaceAll('ς', 'σ'),
).runes) {
final char = String.fromCharCode(rune);
out.write(_bare[char] ?? char);
}
return out.toString();
}
/// Each accented letter (lower case) and its bare one.
final _bare = {
for (final (accented, bare) in const [
('àáâãäåāăąǎ', 'a'),
('çćĉċč', 'c'),
('ďđ', 'd'),
('èéêëēĕėęě', 'e'),
('ĝğġģ', 'g'),
('ĥħ', 'h'),
('ìíîïĩīĭįıǐ', 'i'),
('ĵ', 'j'),
('ķ', 'k'),
('ĺļľŀł', 'l'),
('ñńņňʼn', 'n'),
('òóôõöøōŏőǒ', 'o'),
('ŕŗř', 'r'),
('śŝşšș', 's'),
('ţťŧț', 't'),
('ùúûüũūŭůűųǔǖǘǚǜ', 'u'),
('ŵ', 'w'),
('ýÿŷ', 'y'),
('źżž', 'z'),
('άἀἁἄἅὰᾶ', 'α'),
('έἐἑἔἕὲ', 'ε'),
('ήἠἡἤἥὴῆ', 'η'),
('ίϊΐἰἱἴἵὶῖ', 'ι'),
('όὀὁὄὅὸ', 'ο'),
('ύϋΰὐὑὔὕὺῦ', 'υ'),
('ώὠὡὤὥὼῶ', 'ω'),
('ῥ', 'ρ'),
])
for (final char in accented.split('')) char: bare,
};
/// Whether a card whose [searchText] is [searchTextOf] holds [query]: as /// Whether a card whose [searchText] is [searchTextOf] holds [query]: as
/// typed, or romaji typed for its kana (`taberu` finds たべる). /// typed, with German's umlauts written out as typed without them
/// (`maedchen` finds Mädchen), or romaji typed for its kana (`taberu`
/// finds たべる).
bool matchesQuery(String searchText, String query) { bool matchesQuery(String searchText, String query) {
final typed = searchFolded(query.trim()); final typed = searchFolded(query.trim());
if (typed.isEmpty) { if (typed.isEmpty) {
return true; return true;
} }
return searchText.contains(typed) || return searchText.contains(typed) ||
searchText.contains(
typed.replaceAll('ae', 'a').replaceAll('oe', 'o').replaceAll('ue', 'u'),
) ||
searchText.contains(romajiToHiragana(typed, finished: true)); searchText.contains(romajiToHiragana(typed, finished: true));
} }

View file

@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
void main() {
test('a search finds words of any language as typed on any keyboard: '
'without accents, tone marks or umlauts, ß as ss, kana by romaji', () {
bool finds(String text, String query) =>
matchesQuery(searchFolded(text), query);
expect(finds('Straße', 'strasse'), isTrue);
expect(finds('Mädchen', 'madchen'), isTrue);
expect(finds('Mädchen', 'maedchen'), isTrue);
expect(finds('το σπίτι', 'σπιτι'), isTrue);
expect(finds('ο δρόμος', 'δρομος'), isTrue);
expect(finds('xué xí', 'xue xi'), isTrue);
expect(finds('nǚ ér', 'nu er'), isTrue);
expect(finds('café', 'CAFE'), isTrue);
expect(finds('たべる', 'taberu'), isTrue);
expect(finds('テレビ', 'てれび'), isTrue);
expect(finds('Haus', 'maus'), isFalse);
});
test('A–Z compares letters as their bare ones: Ärger among the a, xué '
'beside xue', () {
expect(searchFolded('Ärger'), 'arger');
expect(searchFolded('xué'), 'xue');
expect(searchFolded('ὁδός'), 'οδοσ');
});
}