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:
Hermes (Server)
2026-08-23 20:08:40 +02:00
co-authored by Claude Opus 5
parent d15867d962
commit ca28264c0e
14 changed files with 1247 additions and 182 deletions
+85 -22
View File
@@ -5,6 +5,91 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
### 🔎 Die Suche trennt jetzt nach Titel, Künstler, Kategorien und Listen (2026-08-23)
Der Suchen-Tab war bisher **59 Zeilen**: ein Textfeld über einer flachen
Liederliste. Wer „Nightcore“ eingab, bekam alle Titel dieses Künstlers
untereinander — den Künstler selbst, seine Kategorie oder eine gleichnamige
Wiedergabeliste fand er nicht.
- 🗂️ **Vier Abschnitte statt einer Liste.** Titel · Künstler · Kategorien ·
Wiedergabelisten, jeder mit Überschrift und Trefferzahl. Alben stecken in
„Kategorien“ — der Album-Name *ist* in Melo die erste Kategorie eines
Titels, ein eigener Alben-Abschnitt wäre dieselbe Liste zweimal.
- ⬆️ **Treffer am Wortanfang stehen oben.** Wer „roses“ tippt, meint eher
„Roses“ als „Wild Roses“. Innerhalb dieser beiden Ränge alphabetisch.
- ⏱️ **Entprellt (250 ms).** Vorher baute jeder einzelne Tastendruck die
komplette Trefferliste neu auf.
- 🕘 **Letzte acht Suchen.** Sie stehen da, solange das Feld leer ist; Antippen
übernimmt den Begriff. Gemerkt wird ein Begriff erst beim Abschicken —
halbe Wörter aus dem Tippen sind keine Suche, die man wiederholen möchte.
- 🧹 **Der Tab sieht aus wie die anderen.** Er war ein `Scaffold` mit eigener
`AppBar` *innerhalb* eines Tabs, während „Meine Musik“ und „Favoriten“ eine
Kopfzeile im Inhalt haben. Jetzt einheitlich, mit demselben runden Suchfeld
wie auf der Startseite.
- ♻️ **Aufgeräumt, weil es sonst eine dritte Kopie gegeben hätte:** Die
Ansicht „Überschrift + Liederliste“ stand zweimal wortgleich im Baum
(hinter einem Künstler und hinter einer Kategorie). Sie ist jetzt eine
Klasse, die auch die Suche benutzt. Ebenso ist die einzelne Songzeile aus
der Liederliste herausgelöst — die Suche setzt einzelne Zeilen in ihre
Abschnitte, eine ganze Liste wäre dort eine verschachtelte Scrollfläche.
- 🐛 **Beim Bauen selbst gefunden und behoben:** Der Verlauf wurde zuerst
beim Aufbau der Trefferliste geschrieben. Das hätte eine Endlosschleife
ergeben — schreiben löst ein Neuzeichnen aus, das wieder schreibt.
- 📴 **Kein Server-Teil.** Bewusst so entschieden: die Suche läuft rein
lokal, damit sie sofort antwortet und ohne Netz funktioniert.
- 🔍 **Aus dem Code-Review nachgebessert (die wichtigsten drei):**
**(1)** Die Suche fasste „Neoni", „NEONI" und „Neoni " zu einem Treffer
zusammen — die Titel dazu wurden aber unter der rohen Schreibweise gesucht.
Ergebnis: „0 Titel" und ein leerer Bildschirm beim Antippen. Beide Seiten
benutzen jetzt dieselbe Normalform, und verschiedene Schreibweisen
desselben Künstlers fallen dabei zusammen statt sich gegenseitig zu
verstecken. Dasselbe galt für Kategorien.
**(2)** Eine Kategorie, an der nur noch gelöschte Titel hängen, erschien
als Treffer und führte ins Leere. Solche Treffer fallen jetzt weg.
**(3)** Alle Abschnitte sind auf fünf Einträge gedeckelt, nicht nur die
Titel. Eine Eingabe wie „a" hätte sonst hunderte Künstlerzeilen bei jedem
Neuzeichnen gebaut. Die Gesamtzahl steht ohnehin in der Überschrift.
Dazu kleinere Sachen: die beiden Datenbank-Ströme werden nicht mehr bei
jedem Neuzeichnen neu abonniert, der Fehlerfall der Wiedergabelisten wird
behandelt, und das Löschkreuz im Suchfeld erscheint sofort statt nach
250 ms — vorher sprang das Textfeld dabei um eine Knopfbreite enger.
- 🗑️ **Verlauf lässt sich löschen.** Ein „Löschen" neben der Überschrift.
(Ohne das hätte es keine Möglichkeit gegeben, gespeicherte Suchbegriffe
wieder loszuwerden.)
- ♻️ **Tot geworden und entfernt:** `MeloDb.searchSongs` samt Test — die
alte LIKE-Abfrage hat nach dem Umbau keinen Aufrufer mehr.
-**Getestet:** 396 Tests grün (vorher 360), `flutter analyze` ohne Befund,
Release-APK gebaut. Neu: 25 Tests für die Suchregeln und die Bündelung
nach Schreibweise, 7 für den Verlauf, 7 Widget-Tests für die Abschnitte
(inklusive einem, der prüft, dass wirklich entprellt wird).
### 🔁 Positions-Timer überlebt jetzt das Wegwischen (2026-08-23)
- ⏱️ **Behoben: nach dem Wegwischen der Benachrichtigung merkte sich die App
keine Hörposition mehr.** `stop()` brach dabei den Timer ab, der alle fünf
Sekunden die Wiedergabeposition sichert — und der entsteht nur **einmal**
beim App-Start. Einmal abgebrochen, kam er nie zurück: „Fortsetzen an alter
Stelle" hörte still auf zu funktionieren, und zum Server wurde nicht mehr
gemeldet, was gehört wurde. Erst ein Neustart half. Der Timer läuft jetzt
durch; er kostet nichts, weil er von selbst aussteigt, solange nichts
spielt. Abgebrochen wird er nur noch beim endgültigen Aufräumen.
- 📄 **Korrektur am eigenen Review-Bericht (K4).** Dort stand, die
Alben-Ansicht sei „verlorengegangen". Das war falsch: der Alben-Reiter wurde
**bewusst** durch „Kategorie" ersetzt — der Album-Name eines Liedes *ist* in
Melo seine erste Kategorie (`category_list.dart:12`, `kategorienAusTags`).
Was wirklich fehlt, ist die Album-**Detailansicht** (großes Cover,
Künstler, Gesamtdauer) und die Sortierung nach Tracknummer — letztere
braucht zuerst eine Schema-Erweiterung, weil die Tracknummer bisher
überhaupt nicht eingelesen wird. Im Bericht ist der Punkt richtiggestellt.
-**Getestet:** 360 Tests grün, `flutter analyze` ohne Befund.
### 🚑 Fünf P0-Fehler behoben: Benachrichtigung, Offline, Cover, Warteschlange (2026-08-23)
Ergebnis einer vollständigen Durchsicht der App gegen den Maßstab „Apple
@@ -92,28 +177,6 @@ die die App gab und der Code nicht hielt**.
mehr — vorbestehend), und die Server-Cover haben nur einen Zwischenspeicher
im Arbeitsspeicher, keinen auf der Platte.
### 🔁 Positions-Timer überlebt jetzt das Wegwischen (2026-08-23)
- ⏱️ **Behoben: nach dem Wegwischen der Benachrichtigung merkte sich die App
keine Hörposition mehr.** `stop()` brach dabei den Timer ab, der alle fünf
Sekunden die Wiedergabeposition sichert — und der entsteht nur **einmal**
beim App-Start. Einmal abgebrochen, kam er nie zurück: „Fortsetzen an alter
Stelle" hörte still auf zu funktionieren, und zum Server wurde nicht mehr
gemeldet, was gehört wurde. Erst ein Neustart half. Der Timer läuft jetzt
durch; er kostet nichts, weil er von selbst aussteigt, solange nichts
spielt. Abgebrochen wird er nur noch beim endgültigen Aufräumen.
- 📄 **Korrektur am eigenen Review-Bericht (K4).** Dort stand, die
Alben-Ansicht sei „verlorengegangen". Das war falsch: der Alben-Reiter wurde
**bewusst** durch „Kategorie" ersetzt — der Album-Name eines Liedes *ist* in
Melo seine erste Kategorie (`category_list.dart:12`, `kategorienAusTags`).
Was wirklich fehlt, ist die Album-**Detailansicht** (großes Cover,
Künstler, Gesamtdauer) und die Sortierung nach Tracknummer — letztere
braucht zuerst eine Schema-Erweiterung, weil die Tracknummer bisher
überhaupt nicht eingelesen wird. Im Bericht ist der Punkt richtiggestellt.
-**Getestet:** 360 Tests grün, `flutter analyze` ohne Befund.
### 🏷️ Der zweite Tab heißt wieder „Download" (2026-08-21)
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
+3 -16
View File
@@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
import 'database.dart';
import 'song_grouping.dart';
import 'song_list.dart';
import '../shared/titel_listen_screen.dart';
import '../shared/theme.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
@@ -35,7 +35,8 @@ class ArtistListScreen extends StatelessWidget {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _ArtistSongsScreen(artist: artist, songs: artistSongs),
builder: (_) =>
TitelListenScreen(titel: artist, songs: artistSongs),
),
),
);
@@ -46,17 +47,3 @@ class ArtistListScreen extends StatelessWidget {
}
}
/// Zeigt alle Songs eines Künstlers.
class _ArtistSongsScreen extends StatelessWidget {
const _ArtistSongsScreen({required this.artist, required this.songs});
final String artist;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(artist)),
body: SongList(songs),
);
}
}
+2 -17
View File
@@ -6,7 +6,7 @@ import '../shared/cover.dart';
import 'categories.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/titel_listen_screen.dart';
import '../shared/theme.dart';
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
@@ -57,7 +57,7 @@ class CategoryListScreen extends StatelessWidget {
context,
MaterialPageRoute(
builder: (_) =>
_KategorieLiederScreen(name: name, songs: lieder),
TitelListenScreen(titel: name, songs: lieder),
),
),
);
@@ -67,18 +67,3 @@ class CategoryListScreen extends StatelessWidget {
);
}
}
/// Zeigt alle Lieder einer Kategorie.
class _KategorieLiederScreen extends StatelessWidget {
const _KategorieLiederScreen({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(name)),
body: SongList(songs),
);
}
}
-17
View File
@@ -191,23 +191,6 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped.
Stream<List<Song>> searchSongs(String query) {
final escaped = query
.toLowerCase()
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
final like = '%$escaped%';
return (select(songs)
..where((s) =>
s.deleted.equals(false) &
(s.title.lower().like(like, escapeChar: '\\') |
s.artist.lower().like(like, escapeChar: '\\') |
s.album.lower().like(like, escapeChar: '\\')))
..orderBy([(s) => OrderingTerm(expression: s.title)]))
.watch();
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
+401 -37
View File
@@ -1,10 +1,30 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlist_detail_screen.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/theme.dart';
import 'suche.dart';
import 'such_verlauf.dart';
/// Wie lange nach dem letzten Tastendruck gewartet wird, bevor gesucht wird.
/// Ohne diese Pause baut jeder Buchstabe die ganze Trefferliste neu auf.
const _entprellung = Duration(milliseconds: 250);
/// Wie viele Einträge ein Abschnitt höchstens zeigt.
///
/// Gilt für alle vier: eine kurze Eingabe wie „a" trifft in einer großen
/// Bibliothek leicht hunderte Künstler, und die würden sonst alle bei jedem
/// Neuzeichnen gebaut. Die Gesamtzahl steht in der Überschrift, und die
/// Sortierung stellt die passendsten nach vorn.
const _vorschau = 5;
/// Suchen-Tab: findet in der Bibliothek und trennt die Treffer nach Art.
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -13,47 +33,391 @@ class SearchScreen extends StatefulWidget {
}
class _SearchScreenState extends State<SearchScreen> {
final _feld = TextEditingController();
Timer? _entprellen;
// Einmal anlegen, nicht in jedem build: der Bildschirm hängt über
// CategoryService auch an jedem Scan-Fortschritt, und ein neuer Stream je
// Rebuild würde die Abfrage laufend neu ausführen.
late final Stream<List<Song>> _songs = context.read<MeloDb>().watchSongs();
late final Stream<List<Playlist>> _listen =
context.read<MeloDb>().watchPlaylists();
String _query = '';
List<String> _verlauf = const [];
@override
void initState() {
super.initState();
SuchVerlauf.laden().then((v) {
if (mounted) setState(() => _verlauf = v);
});
}
@override
void dispose() {
_entprellen?.cancel();
_feld.dispose();
super.dispose();
}
void _tippt(String wert) {
_entprellen?.cancel();
_entprellen = Timer(_entprellung, () {
if (mounted) setState(() => _query = wert.trim());
});
}
/// Merkt den Begriff, wenn der Nutzer die Suche abschickt.
///
/// Bewusst nicht beim Tippen und erst recht nicht beim Aufbau der Treffer:
/// von dort aus würde jeder Rebuild schreiben und über `setState` den
/// nächsten Rebuild auslösen. Abschicken ist außerdem das ehrlichere
/// Signal — halbe Wörter beim Tippen sind keine Suche, die man wiederholen
/// möchte.
Future<void> _merke(String begriff) async {
if (begriff.trim().isEmpty) return;
final neu = await SuchVerlauf.merke(begriff);
if (mounted) setState(() => _verlauf = neu);
}
void _uebernimm(String begriff) {
_entprellen?.cancel();
_feld.text = begriff;
setState(() => _query = begriff);
}
Future<void> _verlaufLeeren() async {
await SuchVerlauf.leeren();
if (mounted) setState(() => _verlauf = const []);
}
void _leeren() {
_entprellen?.cancel();
_feld.clear();
setState(() => _query = '');
}
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(
title: TextField(
autofocus: false,
decoration: const InputDecoration(
hintText: 'Titel, Künstler, Album …',
border: InputBorder.none,
prefixIcon: Icon(Icons.search),
),
onChanged: (v) => setState(() => _query = v.trim()),
),
),
body: _query.isEmpty
? const Center(
child: Text('Suchbegriff eingeben',
style: TextStyle(color: MeloTheme.text2)),
)
: StreamBuilder<List<Song>>(
stream: db.searchSongs(_query),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Nichts gefunden',
style: TextStyle(color: MeloTheme.text2)),
);
}
return SongList(songs);
},
final kategorien = context.watch<CategoryService>().alleNamen;
return SafeArea(
bottom: false,
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Suchen',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
_Suchfeld(
controller: _feld,
onChanged: _tippt,
onAbschicken: _merke,
onLeeren: _leeren,
),
Expanded(
child: _query.isEmpty
? _Verlauf(
begriffe: _verlauf,
onWaehlen: _uebernimm,
onLeeren: _verlaufLeeren,
)
: StreamBuilder<List<Song>>(
stream: _songs,
builder: (context, songSnap) {
if (songSnap.hasError) {
return _Hinweis('Fehler: ${songSnap.error}');
}
return StreamBuilder<List<Playlist>>(
stream: _listen,
builder: (context, listenSnap) {
if (listenSnap.hasError) {
return _Hinweis('Fehler: ${listenSnap.error}');
}
final treffer = sucheLokal(
songs: songSnap.data ?? const [],
kategorien: kategorien,
playlisten: listenSnap.data ?? const [],
query: _query,
);
if (treffer.istLeer) {
return const _Hinweis('Nichts gefunden');
}
return _Ergebnisse(
treffer: treffer,
alleSongs: songSnap.data ?? const [],
);
},
);
},
),
),
],
),
);
}
}
class _Suchfeld extends StatelessWidget {
const _Suchfeld({
required this.controller,
required this.onChanged,
required this.onAbschicken,
required this.onLeeren,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final ValueChanged<String> onAbschicken;
final VoidCallback onLeeren;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm),
child: Container(
height: 44,
decoration: BoxDecoration(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const SizedBox(width: MeloSpace.md - 2),
const Icon(Icons.search, color: MeloTheme.text2, size: 20),
const SizedBox(width: MeloSpace.sm + 2),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
onSubmitted: onAbschicken,
textInputAction: TextInputAction.search,
style: const TextStyle(fontSize: 14, color: MeloTheme.text1),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Titel, Künstler, Kategorien und Listen',
hintStyle: TextStyle(color: MeloTheme.text2, fontSize: 14),
),
),
),
// Feste Breite und direkt am Controller: sonst erschiene das X
// erst nach der Entprellung — und würde das Textfeld dabei um
// die Knopfbreite enger springen lassen.
SizedBox(
width: MeloTheme.minTouchTarget,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, wert, _) => wert.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Eingabe löschen',
icon: const Icon(Icons.close, size: 20),
onPressed: onLeeren,
),
),
),
],
),
),
);
}
}
/// Letzte Suchbegriffe, solange das Feld leer ist.
class _Verlauf extends StatelessWidget {
const _Verlauf({
required this.begriffe,
required this.onWaehlen,
required this.onLeeren,
});
final List<String> begriffe;
final ValueChanged<String> onWaehlen;
final VoidCallback onLeeren;
@override
Widget build(BuildContext context) {
if (begriffe.isEmpty) {
return const _Hinweis('Suchbegriff eingeben');
}
return ListView(
children: [
Row(
children: [
const Expanded(child: _AbschnittsKopf(label: 'Zuletzt gesucht')),
TextButton(onPressed: onLeeren, child: const Text('Löschen')),
const SizedBox(width: MeloSpace.sm),
],
),
for (final begriff in begriffe)
ListTile(
leading: const Icon(Icons.history),
title: Text(begriff),
onTap: () => onWaehlen(begriff),
),
],
);
}
}
/// Die Treffer, nach Art getrennt.
class _Ergebnisse extends StatelessWidget {
const _Ergebnisse({required this.treffer, required this.alleSongs});
final SuchTreffer treffer;
/// Grundlage für die Titel eines Künstlers — die Suche liefert nur die
/// Namen, die Zuordnung steckt in der Bibliothek.
final List<Song> alleSongs;
void _oeffne(BuildContext context, String titel, List<Song> songs) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TitelListenScreen(titel: titel, songs: songs),
));
}
@override
Widget build(BuildContext context) {
final dienst = context.watch<CategoryService>();
// Einmal bündeln statt je Treffer die ganze Bibliothek zu filtern — und
// unter demselben Schlüssel, den die Suche zum Zusammenfassen benutzt.
final nachKuenstler = kuenstlerNachSchluessel(alleSongs);
final nachKategorie =
kategorienNachSchluessel(alleSongs, (s) => dienst.of(s.id));
// Eine Kategorie, an der nur noch gelöschte Titel hängen, steht weiter in
// der Namensliste des Dienstes. Als Treffer wäre sie eine Sackgasse:
// „0 Titel" und ein leerer Bildschirm dahinter.
final kategorien = [
for (final name in treffer.kategorien)
if (nachKategorie.containsKey(suchSchluessel(name))) name,
];
return ListView(
children: [
if (treffer.titel.isNotEmpty) ...[
_AbschnittsKopf(label: 'Titel', anzahl: treffer.titel.length),
for (var i = 0; i < treffer.titel.length && i < _vorschau; i++)
SongZeile(
song: treffer.titel[i],
warteschlange: treffer.titel,
index: i,
),
if (treffer.titel.length > _vorschau)
_MehrZeile(
label: 'Alle ${treffer.titel.length} Titel anzeigen',
onTap: () => _oeffne(context, 'Titel', treffer.titel),
),
],
if (treffer.kuenstler.isNotEmpty) ...[
_AbschnittsKopf(label: 'Künstler', anzahl: treffer.kuenstler.length),
for (final name in treffer.kuenstler.take(_vorschau))
_NamensZeile(
symbol: Icons.person_outline,
name: name,
songs: nachKuenstler[suchSchluessel(name)] ?? const [],
onTap: _oeffne,
),
],
if (kategorien.isNotEmpty) ...[
_AbschnittsKopf(label: 'Kategorien', anzahl: kategorien.length),
for (final name in kategorien.take(_vorschau))
_NamensZeile(
symbol: Icons.label_outline,
name: name,
songs: nachKategorie[suchSchluessel(name)] ?? const [],
onTap: _oeffne,
),
],
if (treffer.playlisten.isNotEmpty) ...[
_AbschnittsKopf(
label: 'Wiedergabelisten', anzahl: treffer.playlisten.length),
for (final liste in treffer.playlisten.take(_vorschau))
ListTile(
leading: const Icon(Icons.queue_music),
title:
Text(liste.name, maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(playlist: liste),
)),
),
],
const SizedBox(height: MeloSpace.md),
],
);
}
}
/// Zeile für einen Künstler oder eine Kategorie: Name und Titelzahl.
class _NamensZeile extends StatelessWidget {
const _NamensZeile({
required this.symbol,
required this.name,
required this.songs,
required this.onTap,
});
final IconData symbol;
final String name;
final List<Song> songs;
final void Function(BuildContext, String, List<Song>) onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(symbol),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${songs.length} Titel'),
onTap: () => onTap(context, name, songs),
);
}
}
class _AbschnittsKopf extends StatelessWidget {
const _AbschnittsKopf({required this.label, this.anzahl});
final String label;
final int? anzahl;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs),
child: Text(
anzahl == null ? label.toUpperCase() : '${label.toUpperCase()} $anzahl',
style: Theme.of(context).textTheme.labelLarge,
),
);
}
}
class _MehrZeile extends StatelessWidget {
const _MehrZeile({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(label, style: const TextStyle(color: MeloTheme.red)),
onTap: onTap,
);
}
}
class _Hinweis extends StatelessWidget {
const _Hinweis(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Text(text, style: const TextStyle(color: MeloTheme.text2)),
);
}
}
+92 -64
View File
@@ -18,8 +18,47 @@ class SongList extends StatelessWidget {
const SongList(this.songs, {super.key});
final List<Song> songs;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: songs.length,
itemBuilder: (context, i) => SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
),
);
}
}
/// Eine Zeile der Songliste: Cover, Titel, Untertitel, Herz und Menü.
///
/// Eigenständig, weil die Suche einzelne Zeilen in ihre Abschnitte setzt und
/// eine ganze [SongList] dort eine verschachtelte Scroll-Fläche wäre.
///
/// [warteschlange] ist die Liste, die beim Antippen geladen wird — in der
/// Bibliothek die ganze Liste, in der Suche der Titel-Abschnitt.
class SongZeile extends StatelessWidget {
const SongZeile({
super.key,
required this.song,
required this.warteschlange,
required this.index,
});
final Song song;
final List<Song> warteschlange;
final int index;
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
if (categories.isEmpty) return artist;
return '$artist | ${categories.join(' · ')}';
}
/// Öffnet ein Bottom-Sheet mit allen Playlisten, um [song] hinzuzufügen.
Future<void> _showAddToPlaylist(BuildContext context, Song song) async {
Future<void> _showAddToPlaylist(BuildContext context) async {
final db = context.read<MeloDb>();
final service = context.read<PlaylistService>();
await showModalBottomSheet(
@@ -75,20 +114,13 @@ class SongList extends StatelessWidget {
);
}
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
if (categories.isEmpty) return artist;
return '$artist | ${categories.join(' · ')}';
}
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
///
/// Die Offline-Meldungen greifen hier heute nie: diese Liste zeigt nur
/// Titel der Bibliothek, und die liegen als Datei vor. Sie stehen trotzdem
/// da, weil die Alternative — den Rückgabewert wegwerfen und "hinzugefügt"
/// melden — still lügen würde, sobald ein Server-Titel hier ankommt.
Future<void> _showMenu(BuildContext context, Song song) async {
Future<void> _showMenu(BuildContext context) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
@@ -119,9 +151,8 @@ class SongList extends StatelessWidget {
Navigator.pop(sheetContext);
final ok = await handler.playNext(item());
messenger.showSnackBar(SnackBar(
content: Text(ok
? 'Läuft als Nächstes'
: nichtOfflineHinweis)));
content: Text(
ok ? 'Läuft als Nächstes' : nichtOfflineHinweis)));
},
),
ListTile(
@@ -143,7 +174,7 @@ class SongList extends StatelessWidget {
title: const Text('Zu Wiedergabeliste hinzufügen'),
onTap: () {
Navigator.pop(sheetContext);
_showAddToPlaylist(context, song);
_showAddToPlaylist(context);
},
),
],
@@ -154,60 +185,57 @@ class SongList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
// Der Handler wird erst beim Antippen gebraucht und deshalb auch erst
// dann gelesen: so lässt sich eine Zeile darstellen, ohne dass ein
// laufender Player bereitstehen muss.
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
return ListView.builder(
itemCount: songs.length,
itemBuilder: (context, i) {
final s = songs[i];
final cover = categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
final cover = categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(song.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_subtitle(song, categories.of(song.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: song.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_subtitle(s, categories.of(s.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: s.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context, s),
),
],
),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
try {
final uebersprungen = await playSongs(
handler,
songs,
i,
coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
);
],
),
onTap: () async {
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
try {
final uebersprungen = await playSongs(
handler,
warteschlange,
index,
coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
);
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Wie viele Suchbegriffe gemerkt werden. Mehr passen nicht auf den Schirm,
/// ohne dass die Liste selbst zum Suchproblem wird.
const verlaufMaximum = 8;
/// [verlauf] mit [begriff] vorn: der zuletzt gesuchte Begriff steht oben,
/// jeder Begriff nur einmal, insgesamt höchstens [verlaufMaximum].
///
/// Rein und ohne Speicher — die Reihenfolge-Regeln sind der Teil, der
/// schiefgehen kann.
List<String> verlaufMit(List<String> verlauf, String begriff) {
final neu = begriff.trim();
if (neu.isEmpty) return List<String>.from(verlauf);
final ergebnis = [
neu,
for (final alt in verlauf)
if (alt.toLowerCase() != neu.toLowerCase()) alt,
];
return ergebnis.take(verlaufMaximum).toList();
}
/// Merkt sich die letzten Suchbegriffe über einen App-Neustart hinweg.
/// Aufbau wie [SortStore]: statische Methoden auf SharedPreferences.
class SuchVerlauf {
static const _key = 'such_verlauf';
static Future<List<String>> laden() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(_key) ?? const [];
}
/// Trägt [begriff] ein und gibt den neuen Verlauf zurück.
static Future<List<String>> merke(String begriff) async {
final prefs = await SharedPreferences.getInstance();
final neu = verlaufMit(prefs.getStringList(_key) ?? const [], begriff);
await prefs.setStringList(_key, neu);
return neu;
}
static Future<void> leeren() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}
+142
View File
@@ -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());
});
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import '../library/database.dart';
import '../library/song_list.dart';
/// Vollbild-Liste einer festen Titelmenge unter einer Überschrift — die
/// Ansicht hinter einem Künstler, einer Kategorie oder einem Suchabschnitt.
///
/// Vorher stand dieselbe Klasse zweimal privat im Baum (`_ArtistSongsScreen`,
/// `_KategorieLiederScreen`); die Suche hätte eine dritte Kopie gebraucht.
class TitelListenScreen extends StatelessWidget {
const TitelListenScreen({
super.key,
required this.titel,
required this.songs,
});
final String titel;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(titel)),
body: SongList(songs),
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/search_screen.dart';
import 'package:melo/settings/app_settings.dart';
/// Die Suche zeigte früher eine flache Liederliste. Jetzt trennt sie die
/// Treffer nach Art — dieser Test prüft, dass die Abschnitte erscheinen und
/// nur die passenden Treffer enthalten.
void main() {
late MeloDb db;
late CategoryService kategorien;
late AppSettings einstellungen;
late PlaylistService playlists;
setUp(() async {
// Jeder Test legt eine eigene In-Memory-Datenbank an; die Warnung über
// mehrere Instanzen ist hier erwartet und kein Befund.
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
Future<void> song(String id, String titel, String kuenstler) =>
db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/music/$id.mp3',
title: titel,
artist: Value(kuenstler),
dateAddedMs: 0,
updatedAtMs: 0,
));
await song('s1', 'Roses', 'Neoni');
await song('s2', 'Demons', 'Nightcore Gaming');
await song('s3', 'Wild Roses', 'Lori');
await db.setCategories('s1', ['Trap cool']);
await db.createPlaylist('Rosengarten');
});
// Kein db.close(): in Widget-Tests plant drift dabei eine Aufräum-Aufgabe
// ein, die der Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md). Die
// Dienste liegen als `.value` im Baum, werden also nicht von ihm entsorgt.
tearDown(() {
kategorien.dispose();
playlists.dispose();
});
Future<void> pumpe(WidgetTester tester) async {
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
],
child: const MaterialApp(home: Scaffold(body: SearchScreen())),
),
);
await tester.pumpAndSettle();
}
Future<void> tippe(WidgetTester tester, String text) async {
await tester.enterText(find.byType(TextField), text);
// Die Eingabe ist entprellt — ohne dieses Warten passiert nichts.
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
}
Future<void> aufraeumen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets('leeres Feld zeigt den Hinweis statt Treffer', (tester) async {
await pumpe(tester);
expect(find.text('Suchbegriff eingeben'), findsOneWidget);
expect(find.text('TITEL 2'), findsNothing);
await aufraeumen(tester);
});
testWidgets('trennt Treffer nach Titel, Künstler und Listen',
(tester) async {
await pumpe(tester);
await tippe(tester, 'rose');
expect(find.text('TITEL 2'), findsOneWidget);
expect(find.text('WIEDERGABELISTEN 1'), findsOneWidget);
expect(find.text('Roses'), findsOneWidget);
expect(find.text('Wild Roses'), findsOneWidget);
expect(find.text('Rosengarten'), findsOneWidget);
// "Demons" passt nicht und darf nicht auftauchen.
expect(find.text('Demons'), findsNothing);
await aufraeumen(tester);
});
testWidgets('findet Künstler als eigenen Abschnitt', (tester) async {
await pumpe(tester);
await tippe(tester, 'nightcore');
expect(find.text('KÜNSTLER 1'), findsOneWidget);
expect(find.text('Nightcore Gaming'), findsWidgets);
await aufraeumen(tester);
});
testWidgets('findet Kategorien als eigenen Abschnitt', (tester) async {
await pumpe(tester);
await tippe(tester, 'trap');
expect(find.text('KATEGORIEN 1'), findsOneWidget);
expect(find.text('Trap cool'), findsWidgets);
await aufraeumen(tester);
});
testWidgets('ohne Treffer erscheint "Nichts gefunden"', (tester) async {
await pumpe(tester);
await tippe(tester, 'jazzfrühstück');
expect(find.text('Nichts gefunden'), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('entprellt: direkt nach dem Tippen steht noch nichts da',
(tester) async {
await pumpe(tester);
await tester.enterText(find.byType(TextField), 'rose');
// Weniger als die Entprellzeit — die Treffer dürfen noch nicht stehen.
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('TITEL 2'), findsNothing);
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('TITEL 2'), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('abgeschickte Suche steht danach im Verlauf', (tester) async {
await pumpe(tester);
await tippe(tester, 'rose');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// Feld leeren — dann muss der Verlauf sichtbar werden.
await tester.tap(find.byTooltip('Eingabe löschen'));
await tester.pumpAndSettle();
expect(find.text('ZULETZT GESUCHT'), findsOneWidget);
expect(find.text('rose'), findsOneWidget);
await aufraeumen(tester);
});
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/such_verlauf.dart';
/// Die Reihenfolge-Regeln des Verlaufs — ohne SharedPreferences prüfbar.
void main() {
group('verlaufMit', () {
test('legt den neuen Begriff nach vorn', () {
expect(verlaufMit(['neoni', 'rock'], 'trap'), ['trap', 'neoni', 'rock']);
});
test('ein erneut gesuchter Begriff wandert nach vorn statt sich zu doppeln',
() {
expect(verlaufMit(['neoni', 'rock'], 'rock'), ['rock', 'neoni']);
});
test('Groß- und Kleinschreibung gilt als derselbe Begriff', () {
expect(verlaufMit(['Rock'], 'rock'), ['rock']);
});
test('Leerraum am Rand zählt nicht mit', () {
expect(verlaufMit(['rock'], ' rock '), ['rock']);
});
test('deckelt bei acht Einträgen', () {
final voll = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
final neu = verlaufMit(voll, 'i');
expect(neu.length, 8);
expect(neu.first, 'i');
// Der älteste fällt hinten heraus.
expect(neu, isNot(contains('h')));
});
test('ein leerer Begriff ändert nichts', () {
expect(verlaufMit(['rock'], ' '), ['rock']);
});
test('lässt die Ausgangsliste unverändert', () {
final vorher = ['rock'];
verlaufMit(vorher, 'trap');
expect(vorher, ['rock']);
});
});
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/suche.dart';
Song _song(String titel, {String? kuenstler, String? album, String? id}) => Song(
id: id ?? titel.toLowerCase().replaceAll(' ', '-'),
path: '/music/$titel.mp3',
title: titel,
artist: kuenstler,
album: album,
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
Playlist _liste(String name) => Playlist(
id: name.toLowerCase(),
name: name,
createdAtMs: 0,
updatedAtMs: 0,
deleted: false,
);
SuchTreffer _suche(
String query, {
List<Song>? songs,
List<String>? kategorien,
List<Playlist>? playlisten,
}) =>
sucheLokal(
songs: songs ?? const [],
kategorien: kategorien ?? const [],
playlisten: playlisten ?? const [],
query: query,
);
void main() {
group('leere Eingabe', () {
test('findet nichts', () {
final treffer = _suche('', songs: [_song('Roses')]);
expect(treffer.istLeer, isTrue);
});
test('nur Leerzeichen zählen als leer', () {
final treffer = _suche(' ', songs: [_song('Roses')]);
expect(treffer.istLeer, isTrue);
});
});
group('Titel', () {
final songs = [
_song('Roses', kuenstler: 'Neoni', album: 'Trailerpark'),
_song('Demons', kuenstler: 'Nightcore'),
_song('Wild Roses', kuenstler: 'Lori'),
];
test('findet über den Titel', () {
expect(_suche('demons', songs: songs).titel.map((s) => s.title),
['Demons']);
});
test('findet über den Künstler', () {
expect(_suche('neoni', songs: songs).titel.map((s) => s.title),
['Roses']);
});
test('findet über das Album', () {
expect(_suche('trailerpark', songs: songs).titel.map((s) => s.title),
['Roses']);
});
test('ignoriert Groß- und Kleinschreibung', () {
expect(_suche('ROSES', songs: songs).titel.length, 2);
});
test('Treffer am Wortanfang stehen vorn', () {
// "Roses" beginnt mit der Eingabe, "Wild Roses" enthält sie nur.
expect(_suche('roses', songs: songs).titel.map((s) => s.title),
['Roses', 'Wild Roses']);
});
test('ein Titel erscheint nur einmal, auch wenn mehrere Felder passen', () {
final doppelt = [_song('Neoni', kuenstler: 'Neoni', album: 'Neoni')];
expect(_suche('neoni', songs: doppelt).titel.length, 1);
});
});
group('Künstler', () {
final songs = [
_song('A', kuenstler: 'Nightcore Gaming'),
_song('B', kuenstler: 'Nightcore Gaming'),
_song('C', kuenstler: 'Anime Nightcore'),
_song('D', kuenstler: 'Neoni'),
_song('E'),
];
test('nennt jeden Künstler nur einmal', () {
expect(_suche('nightcore', songs: songs).kuenstler,
['Nightcore Gaming', 'Anime Nightcore']);
});
test('Treffer am Wortanfang stehen vorn', () {
expect(_suche('nightcore', songs: songs).kuenstler.first,
'Nightcore Gaming');
});
test('Titel ohne Künstler erzeugen keinen leeren Eintrag', () {
expect(_suche('e', songs: songs).kuenstler, isNot(contains('')));
});
});
group('Kategorien', () {
const kategorien = ['Trap cool', 'Trap neu', 'Nightcore', 'Rock'];
test('findet passende Kategorien', () {
expect(_suche('trap', kategorien: kategorien).kategorien,
['Trap cool', 'Trap neu']);
});
test('findet nichts Unpassendes', () {
expect(_suche('jazz', kategorien: kategorien).kategorien, isEmpty);
});
});
group('Wiedergabelisten', () {
final listen = [_liste('Sport'), _liste('Sonntagmorgen')];
test('findet über den Namen', () {
expect(_suche('sonn', playlisten: listen).playlisten.map((p) => p.name),
['Sonntagmorgen']);
});
});
group('Zusammenspiel', () {
test('zählt alle Abschnitte zusammen', () {
final treffer = _suche(
'rock',
songs: [_song('Rock Anthem', kuenstler: 'Rocker')],
kategorien: ['Rock'],
playlisten: [_liste('Rock-Liste')],
);
expect(treffer.titel.map((s) => s.title), ['Rock Anthem']);
expect(treffer.kuenstler, ['Rocker']);
expect(treffer.kategorien, ['Rock']);
expect(treffer.playlisten.map((p) => p.name), ['Rock-Liste']);
expect(treffer.istLeer, isFalse);
});
test('ohne Treffer ist das Ergebnis leer, nicht null', () {
final treffer = _suche('gibtesnicht', songs: [_song('Roses')]);
expect(treffer.istLeer, isTrue);
expect(treffer.titel, isEmpty);
expect(treffer.kuenstler, isEmpty);
});
});
group('kuenstlerNachSchluessel', () {
test('fasst Schreibweisen zusammen, wie es die Suche tut', () {
// Genau der Bruch, der "0 Titel" und einen leeren Bildschirm ergab:
// die Suche nannte einen Künstler, die Zuordnung fand ihn nicht.
final gebuendelt = kuenstlerNachSchluessel([
_song('A', kuenstler: 'Neoni'),
_song('B', kuenstler: 'NEONI'),
_song('C', kuenstler: 'Neoni '),
]);
expect(gebuendelt.keys, ['neoni']);
expect(gebuendelt['neoni']!.length, 3);
});
test('der Treffer der Suche findet seine Titel wieder', () {
final songs = [_song('A', kuenstler: 'Neoni '), _song('B', kuenstler: 'NEONI')];
final treffer = sucheLokal(
songs: songs, kategorien: const [], playlisten: const [], query: 'neoni');
final gebuendelt = kuenstlerNachSchluessel(songs);
final name = treffer.kuenstler.single;
expect(gebuendelt[suchSchluessel(name)]!.length, 2);
});
test('Titel ohne Künstler kommen nicht vor', () {
expect(kuenstlerNachSchluessel([_song('A'), _song('B', kuenstler: ' ')]),
isEmpty);
});
});
group('kategorienNachSchluessel', () {
test('fasst Schreibweisen zusammen', () {
final a = _song('A', id: 'a');
final b = _song('B', id: 'b');
final gebuendelt = kategorienNachSchluessel(
[a, b],
(s) => s.id == 'a' ? ['Rock'] : ['rock'],
);
expect(gebuendelt.keys, ['rock']);
expect(gebuendelt['rock']!.length, 2);
});
test('ein Titel steht unter jeder seiner Kategorien', () {
final gebuendelt = kategorienNachSchluessel(
[_song('A')],
(_) => ['Rock', 'Chill'],
);
expect(gebuendelt.keys.toSet(), {'rock', 'chill'});
});
test('leere Kategorienamen werden übergangen', () {
expect(kategorienNachSchluessel([_song('A')], (_) => [' ']), isEmpty);
});
});
group('suchSchluessel', () {
test('trimmt und senkt die Schreibweise', () {
expect(suchSchluessel(' NEONI '), 'neoni');
});
});
}
-9
View File
@@ -32,15 +32,6 @@ void main() {
expect(songs.first.title, 'Neu');
});
test('searchSongs findet über Titel und Künstler', () async {
await db.upsertSongs([
song('1', '/a.mp3', 'Nachtpuls', artist: 'Rotklang'),
song('2', '/b.mp3', 'Taglicht', artist: 'Beatzwei'),
]);
expect((await db.searchSongs('nacht').first).length, 1);
expect((await db.searchSongs('rotklang').first).length, 1);
expect((await db.searchSongs('xyz').first).length, 0);
});
test('markMissing setzt Tombstone für nicht aktualisierte Dateien', () async {
final now1 = 100;
@@ -1,3 +1,4 @@
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -10,6 +11,10 @@ import 'package:melo/shared/favorite_button.dart';
/// Früher kam an dieser Stelle der leere String an: das Herz füllte sich,
/// in den Favoriten (INNER JOIN auf songs) tauchte aber nie etwas auf.
void main() {
// Jeder Test legt eine eigene In-Memory-Datenbank an; die Warnung über
// mehrere Instanzen ist hier erwartet und kein Befund.
setUp(() => driftRuntimeOptions.dontWarnAboutMultipleDatabases = true);
Future<void> pumpe(WidgetTester tester, MeloDb db, String songId) {
return tester.pumpWidget(
MultiProvider(