Files
Melo/lib/library/suche.dart
T
Hermes (Server)andClaude Opus 5 ca28264c0e 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
2026-08-23 20:08:40 +02:00

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());
});
}