Der Suchen-Tab war 59 Zeilen: ein Textfeld ueber einer flachen
Liederliste, nur ueber Titel/Kuenstler/Album. Wer "Nightcore" eingab,
fand alle Titel dieses Kuenstlers untereinander, aber weder den
Kuenstler selbst noch eine gleichnamige Kategorie oder Liste.
- Vier Abschnitte mit Ueberschrift und Trefferzahl. Alben stecken in
"Kategorien" — der Album-Name ist in Melo die erste Kategorie eines
Titels, ein eigener Abschnitt waere dieselbe Liste zweimal.
- Treffer am Wortanfang zuerst, dann alphabetisch.
- Entprellt (250 ms); vorher baute jeder Tastendruck alles neu auf.
- Letzte acht Suchen, gemerkt beim Abschicken, mit Loeschen-Knopf.
- Der Tab folgt jetzt dem Aufbau der uebrigen Tabs (war ein Scaffold
mit eigener AppBar innerhalb eines Tabs).
- Bewusst rein lokal, ohne Server-Abfrage.
Herausgeloest, weil es sonst eine dritte Kopie gegeben haette:
TitelListenScreen ("Ueberschrift + Liederliste", stand zweimal wortgleich
im Baum) und SongZeile (die Suche setzt einzelne Zeilen in ihre
Abschnitte; eine ganze SongList waere dort verschachteltes Scrollen).
Aus dem Code-Review nachgebessert: Suche und Zuordnung benutzen dieselbe
Normalform, sonst zeigte ein Treffer "0 Titel" und oeffnete eine leere
Liste; Kategorien ohne lebende Titel fallen weg; alle Abschnitte sind
gedeckelt; die Drift-Stroeme werden nicht mehr je Rebuild neu abonniert;
das Loeschkreuz erscheint sofort statt nach 250 ms.
Tot geworden und entfernt: MeloDb.searchSongs samt Test.
396 Tests gruen (vorher 360), flutter analyze ohne Befund, Release-APK
gebaut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
143 lines
4.6 KiB
Dart
143 lines
4.6 KiB
Dart
import 'database.dart';
|
|
|
|
/// Ergebnis einer Suche in der Bibliothek, nach Art der Treffer getrennt.
|
|
///
|
|
/// Bewusst vier Listen statt einer gemischten: der Nutzer sucht entweder ein
|
|
/// Lied oder einen Künstler, und eine flache Liste zwingt ihn, das selbst
|
|
/// auseinanderzuhalten.
|
|
class SuchTreffer {
|
|
const SuchTreffer({
|
|
this.titel = const [],
|
|
this.kuenstler = const [],
|
|
this.kategorien = const [],
|
|
this.playlisten = const [],
|
|
});
|
|
|
|
final List<Song> titel;
|
|
final List<String> kuenstler;
|
|
final List<String> kategorien;
|
|
final List<Playlist> playlisten;
|
|
|
|
bool get istLeer =>
|
|
titel.isEmpty &&
|
|
kuenstler.isEmpty &&
|
|
kategorien.isEmpty &&
|
|
playlisten.isEmpty;
|
|
|
|
}
|
|
|
|
|
|
/// Normalform eines Namens für den Abgleich.
|
|
///
|
|
/// Die Suche fasst „Neoni", „NEONI" und „Neoni " zu einem Treffer zusammen.
|
|
/// Wer danach die Titel dieses Namens sucht, muss **dieselbe** Normalform
|
|
/// verwenden — sonst zeigt der Treffer „0 Titel" und öffnet eine leere Liste,
|
|
/// weil er auf eine Schreibweise zeigt, die so nirgends gespeichert ist.
|
|
String suchSchluessel(String name) => name.trim().toLowerCase();
|
|
|
|
/// Bündelt [songs] unter [suchSchluessel] ihrer Künstler. Nur Titel mit
|
|
/// Künstlernamen; verschiedene Schreibweisen fallen zusammen.
|
|
Map<String, List<Song>> kuenstlerNachSchluessel(List<Song> songs) {
|
|
final gebuendelt = <String, List<Song>>{};
|
|
for (final song in songs) {
|
|
final name = song.artist?.trim() ?? '';
|
|
if (name.isEmpty) continue;
|
|
gebuendelt.putIfAbsent(suchSchluessel(name), () => []).add(song);
|
|
}
|
|
return gebuendelt;
|
|
}
|
|
|
|
/// Bündelt [songs] unter [suchSchluessel] ihrer Kategorien. [kategorienVon]
|
|
/// liefert die Kategorien eines Titels (wie `CategoryService.of`).
|
|
Map<String, List<Song>> kategorienNachSchluessel(
|
|
List<Song> songs,
|
|
List<String> Function(Song) kategorienVon,
|
|
) {
|
|
final gebuendelt = <String, List<Song>>{};
|
|
for (final song in songs) {
|
|
for (final name in kategorienVon(song)) {
|
|
if (name.trim().isEmpty) continue;
|
|
gebuendelt.putIfAbsent(suchSchluessel(name), () => []).add(song);
|
|
}
|
|
}
|
|
return gebuendelt;
|
|
}
|
|
|
|
/// Durchsucht die Bibliothek. Rein — ohne Datenbank und ohne Oberfläche,
|
|
/// damit die Regeln unten für sich prüfbar sind.
|
|
///
|
|
/// [kategorien] sind alle vergebenen Kategorienamen. Alben stehen dort mit
|
|
/// drin: der Album-Name ist in Melo die erste Kategorie eines Titels (siehe
|
|
/// `kategorienAusTags`), ein eigener Alben-Abschnitt wäre also dieselbe Liste
|
|
/// zweimal.
|
|
SuchTreffer sucheLokal({
|
|
required List<Song> songs,
|
|
required List<String> kategorien,
|
|
required List<Playlist> playlisten,
|
|
required String query,
|
|
}) {
|
|
final gesucht = query.trim().toLowerCase();
|
|
if (gesucht.isEmpty) return const SuchTreffer();
|
|
|
|
return SuchTreffer(
|
|
titel: _sortiere(
|
|
songs.where((s) => _passtSong(s, gesucht)).toList(),
|
|
gesucht,
|
|
(s) => s.title,
|
|
),
|
|
kuenstler: _sortiere(
|
|
_eindeutig(songs.map((s) => s.artist))
|
|
.where((name) => _enthaelt(name, gesucht))
|
|
.toList(),
|
|
gesucht,
|
|
(name) => name,
|
|
),
|
|
kategorien: _sortiere(
|
|
kategorien.where((name) => _enthaelt(name, gesucht)).toList(),
|
|
gesucht,
|
|
(name) => name,
|
|
),
|
|
playlisten: _sortiere(
|
|
playlisten.where((p) => _enthaelt(p.name, gesucht)).toList(),
|
|
gesucht,
|
|
(p) => p.name,
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _enthaelt(String? wert, String gesucht) =>
|
|
wert != null && wert.toLowerCase().contains(gesucht);
|
|
|
|
/// Ein Titel passt, wenn Titel, Künstler **oder** Album passen — er steht
|
|
/// deshalb trotzdem nur einmal in der Trefferliste.
|
|
bool _passtSong(Song s, String gesucht) =>
|
|
_enthaelt(s.title, gesucht) ||
|
|
_enthaelt(s.artist, gesucht) ||
|
|
_enthaelt(s.album, gesucht);
|
|
|
|
/// Nicht-leere Werte, jeder nur einmal, in der Reihenfolge des ersten
|
|
/// Auftretens. Groß-/Kleinschreibung entscheidet nicht über Gleichheit.
|
|
List<String> _eindeutig(Iterable<String?> werte) {
|
|
final gesehen = <String>{};
|
|
final ergebnis = <String>[];
|
|
for (final wert in werte) {
|
|
final name = wert?.trim() ?? '';
|
|
if (name.isEmpty) continue;
|
|
if (gesehen.add(name.toLowerCase())) ergebnis.add(name);
|
|
}
|
|
return ergebnis;
|
|
}
|
|
|
|
/// Treffer, die mit der Eingabe **beginnen**, zuerst — danach alphabetisch.
|
|
/// Wer „roses" tippt, meint eher „Roses" als „Wild Roses".
|
|
List<T> _sortiere<T>(List<T> werte, String gesucht, String Function(T) nameVon) {
|
|
int rang(T wert) =>
|
|
nameVon(wert).toLowerCase().startsWith(gesucht) ? 0 : 1;
|
|
return werte
|
|
..sort((a, b) {
|
|
final unterschied = rang(a).compareTo(rang(b));
|
|
if (unterschied != 0) return unterschied;
|
|
return nameVon(a).toLowerCase().compareTo(nameVon(b).toLowerCase());
|
|
});
|
|
}
|