Suche trennt nach Titel, Kuenstler, Kategorien und Wiedergabelisten
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
d15867d962
commit
ca28264c0e
@@ -0,0 +1,142 @@
|
||||
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());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user