diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5d70c..ea5e14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index 6463f51..5cd0a55 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -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 songs; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(artist)), - body: SongList(songs), - ); - } -} diff --git a/lib/library/category_list.dart b/lib/library/category_list.dart index f223412..ce4d2f8 100644 --- a/lib/library/category_list.dart +++ b/lib/library/category_list.dart @@ -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 songs; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(name)), - body: SongList(songs), - ); - } -} diff --git a/lib/library/database.dart b/lib/library/database.dart index 0d07aa8..5a5f21f 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -191,23 +191,6 @@ class MeloDb extends _$MeloDb { .watch(); } - /// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped. - Stream> 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> allSongs() => select(songs).get(); diff --git a/lib/library/search_screen.dart b/lib/library/search_screen.dart index 2d151c8..a528eba 100644 --- a/lib/library/search_screen.dart +++ b/lib/library/search_screen.dart @@ -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 { + 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> _songs = context.read().watchSongs(); + late final Stream> _listen = + context.read().watchPlaylists(); String _query = ''; + List _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 _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 _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(); - 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>( - 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().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>( + stream: _songs, + builder: (context, songSnap) { + if (songSnap.hasError) { + return _Hinweis('Fehler: ${songSnap.error}'); + } + return StreamBuilder>( + 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 onChanged; + final ValueChanged 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( + 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 begriffe; + final ValueChanged 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 alleSongs; + + void _oeffne(BuildContext context, String titel, List songs) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => TitelListenScreen(titel: titel, songs: songs), + )); + } + + @override + Widget build(BuildContext context) { + final dienst = context.watch(); + // 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 songs; + final void Function(BuildContext, String, List) 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)), ); } } diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index 6ccdfcf..ff8224e 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -18,8 +18,47 @@ class SongList extends StatelessWidget { const SongList(this.songs, {super.key}); final List 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 warteschlange; + final int index; + + /// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf. + static String _subtitle(Song song, List 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 _showAddToPlaylist(BuildContext context, Song song) async { + Future _showAddToPlaylist(BuildContext context) async { final db = context.read(); final service = context.read(); await showModalBottomSheet( @@ -75,20 +114,13 @@ class SongList extends StatelessWidget { ); } - /// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf. - static String _subtitle(Song song, List 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 _showMenu(BuildContext context, Song song) async { + Future _showMenu(BuildContext context) async { final handler = context.read(); final categories = context.read(); final settings = context.read(); @@ -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(); + // 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(); final settings = context.watch(); - 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(); + 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')), + ); + } }, ); } diff --git a/lib/library/such_verlauf.dart b/lib/library/such_verlauf.dart new file mode 100644 index 0000000..bc9ca49 --- /dev/null +++ b/lib/library/such_verlauf.dart @@ -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 verlaufMit(List verlauf, String begriff) { + final neu = begriff.trim(); + if (neu.isEmpty) return List.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> 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> 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 leeren() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } +} diff --git a/lib/library/suche.dart b/lib/library/suche.dart new file mode 100644 index 0000000..61cfe21 --- /dev/null +++ b/lib/library/suche.dart @@ -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 titel; + final List kuenstler; + final List kategorien; + final List 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> kuenstlerNachSchluessel(List songs) { + final gebuendelt = >{}; + 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> kategorienNachSchluessel( + List songs, + List Function(Song) kategorienVon, +) { + final gebuendelt = >{}; + 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 songs, + required List kategorien, + required List 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 _eindeutig(Iterable werte) { + final gesehen = {}; + final ergebnis = []; + 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 _sortiere(List 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()); + }); +} diff --git a/lib/shared/titel_listen_screen.dart b/lib/shared/titel_listen_screen.dart new file mode 100644 index 0000000..10182aa --- /dev/null +++ b/lib/shared/titel_listen_screen.dart @@ -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 songs; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(titel)), + body: SongList(songs), + ); + } +} diff --git a/test/library/search_screen_test.dart b/test/library/search_screen_test.dart new file mode 100644 index 0000000..55eaf21 --- /dev/null +++ b/test/library/search_screen_test.dart @@ -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 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 pumpe(WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: kategorien), + ChangeNotifierProvider.value(value: einstellungen), + ChangeNotifierProvider.value(value: playlists), + ], + child: const MaterialApp(home: Scaffold(body: SearchScreen())), + ), + ); + await tester.pumpAndSettle(); + } + + Future 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 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); + }); +} diff --git a/test/library/such_verlauf_test.dart b/test/library/such_verlauf_test.dart new file mode 100644 index 0000000..60d1cb3 --- /dev/null +++ b/test/library/such_verlauf_test.dart @@ -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']); + }); + }); +} diff --git a/test/library/suche_test.dart b/test/library/suche_test.dart new file mode 100644 index 0000000..ef6df1a --- /dev/null +++ b/test/library/suche_test.dart @@ -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? songs, + List? kategorien, + List? 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'); + }); + }); +} diff --git a/test/library_db_test.dart b/test/library_db_test.dart index d7af5a3..0bccfc9 100644 --- a/test/library_db_test.dart +++ b/test/library_db_test.dart @@ -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; diff --git a/test/shared/favorite_button_ohne_id_test.dart b/test/shared/favorite_button_ohne_id_test.dart index fd03ab2..226deb3 100644 --- a/test/shared/favorite_button_ohne_id_test.dart +++ b/test/shared/favorite_button_ohne_id_test.dart @@ -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 pumpe(WidgetTester tester, MeloDb db, String songId) { return tester.pumpWidget( MultiProvider(