Suche trennt nach Titel, Kuenstler, Kategorien und Wiedergabelisten
Der Suchen-Tab war 59 Zeilen: ein Textfeld ueber einer flachen
Liederliste, nur ueber Titel/Kuenstler/Album. Wer "Nightcore" eingab,
fand alle Titel dieses Kuenstlers untereinander, aber weder den
Kuenstler selbst noch eine gleichnamige Kategorie oder Liste.
- Vier Abschnitte mit Ueberschrift und Trefferzahl. Alben stecken in
"Kategorien" — der Album-Name ist in Melo die erste Kategorie eines
Titels, ein eigener Abschnitt waere dieselbe Liste zweimal.
- Treffer am Wortanfang zuerst, dann alphabetisch.
- Entprellt (250 ms); vorher baute jeder Tastendruck alles neu auf.
- Letzte acht Suchen, gemerkt beim Abschicken, mit Loeschen-Knopf.
- Der Tab folgt jetzt dem Aufbau der uebrigen Tabs (war ein Scaffold
mit eigener AppBar innerhalb eines Tabs).
- Bewusst rein lokal, ohne Server-Abfrage.
Herausgeloest, weil es sonst eine dritte Kopie gegeben haette:
TitelListenScreen ("Ueberschrift + Liederliste", stand zweimal wortgleich
im Baum) und SongZeile (die Suche setzt einzelne Zeilen in ihre
Abschnitte; eine ganze SongList waere dort verschachteltes Scrollen).
Aus dem Code-Review nachgebessert: Suche und Zuordnung benutzen dieselbe
Normalform, sonst zeigte ein Treffer "0 Titel" und oeffnete eine leere
Liste; Kategorien ohne lebende Titel fallen weg; alle Abschnitte sind
gedeckelt; die Drift-Stroeme werden nicht mehr je Rebuild neu abonniert;
das Loeschkreuz erscheint sofort statt nach 250 ms.
Tot geworden und entfernt: MeloDb.searchSongs samt Test.
396 Tests gruen (vorher 360), flutter analyze ohne Befund, Release-APK
gebaut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
co-authored by
Claude Opus 5
parent
d15867d962
commit
ca28264c0e
+85
-22
@@ -5,6 +5,91 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
### 🚑 Fünf P0-Fehler behoben: Benachrichtigung, Offline, Cover, Warteschlange (2026-08-23)
|
||||||
|
|
||||||
Ergebnis einer vollständigen Durchsicht der App gegen den Maßstab „Apple
|
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
|
mehr — vorbestehend), und die Server-Cover haben nur einen Zwischenspeicher
|
||||||
im Arbeitsspeicher, keinen auf der Platte.
|
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)
|
### 🏷️ Der zweite Tab heißt wieder „Download" (2026-08-21)
|
||||||
|
|
||||||
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
|
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
import 'song_grouping.dart';
|
import 'song_grouping.dart';
|
||||||
import 'song_list.dart';
|
import '../shared/titel_listen_screen.dart';
|
||||||
import '../shared/theme.dart';
|
import '../shared/theme.dart';
|
||||||
|
|
||||||
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
|
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
|
||||||
@@ -35,7 +35,8 @@ class ArtistListScreen extends StatelessWidget {
|
|||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import '../shared/cover.dart';
|
|||||||
import 'categories.dart';
|
import 'categories.dart';
|
||||||
import 'category_service.dart';
|
import 'category_service.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
import 'song_list.dart';
|
import '../shared/titel_listen_screen.dart';
|
||||||
import '../shared/theme.dart';
|
import '../shared/theme.dart';
|
||||||
|
|
||||||
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
|
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
|
||||||
@@ -57,7 +57,7 @@ class CategoryListScreen extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -191,23 +191,6 @@ class MeloDb extends _$MeloDb {
|
|||||||
.watch();
|
.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.
|
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
|
||||||
Future<List<Song>> allSongs() => select(songs).get();
|
Future<List<Song>> allSongs() => select(songs).get();
|
||||||
|
|||||||
+401
-37
@@ -1,10 +1,30 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.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 'database.dart';
|
||||||
import 'song_list.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 {
|
class SearchScreen extends StatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
|
|
||||||
@@ -13,47 +33,391 @@ class SearchScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SearchScreenState extends State<SearchScreen> {
|
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 = '';
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final db = context.read<MeloDb>();
|
final kategorien = context.watch<CategoryService>().alleNamen;
|
||||||
return Scaffold(
|
return SafeArea(
|
||||||
appBar: AppBar(
|
bottom: false,
|
||||||
title: TextField(
|
child: Column(
|
||||||
autofocus: false,
|
children: [
|
||||||
decoration: const InputDecoration(
|
const Padding(
|
||||||
hintText: 'Titel, Künstler, Album …',
|
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||||
border: InputBorder.none,
|
child: Align(
|
||||||
prefixIcon: Icon(Icons.search),
|
alignment: Alignment.centerLeft,
|
||||||
),
|
child: Text('Suchen',
|
||||||
onChanged: (v) => setState(() => _query = v.trim()),
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||||
),
|
|
||||||
),
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
_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
@@ -18,8 +18,47 @@ class SongList extends StatelessWidget {
|
|||||||
const SongList(this.songs, {super.key});
|
const SongList(this.songs, {super.key});
|
||||||
final List<Song> songs;
|
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.
|
/// Ö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 db = context.read<MeloDb>();
|
||||||
final service = context.read<PlaylistService>();
|
final service = context.read<PlaylistService>();
|
||||||
await showModalBottomSheet(
|
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.
|
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
|
||||||
///
|
///
|
||||||
/// Die Offline-Meldungen greifen hier heute nie: diese Liste zeigt nur
|
/// Die Offline-Meldungen greifen hier heute nie: diese Liste zeigt nur
|
||||||
/// Titel der Bibliothek, und die liegen als Datei vor. Sie stehen trotzdem
|
/// Titel der Bibliothek, und die liegen als Datei vor. Sie stehen trotzdem
|
||||||
/// da, weil die Alternative — den Rückgabewert wegwerfen und "hinzugefügt"
|
/// da, weil die Alternative — den Rückgabewert wegwerfen und "hinzugefügt"
|
||||||
/// melden — still lügen würde, sobald ein Server-Titel hier ankommt.
|
/// 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 handler = context.read<MeloAudioHandler>();
|
||||||
final categories = context.read<CategoryService>();
|
final categories = context.read<CategoryService>();
|
||||||
final settings = context.read<AppSettings>();
|
final settings = context.read<AppSettings>();
|
||||||
@@ -119,9 +151,8 @@ class SongList extends StatelessWidget {
|
|||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
final ok = await handler.playNext(item());
|
final ok = await handler.playNext(item());
|
||||||
messenger.showSnackBar(SnackBar(
|
messenger.showSnackBar(SnackBar(
|
||||||
content: Text(ok
|
content: Text(
|
||||||
? 'Läuft als Nächstes'
|
ok ? 'Läuft als Nächstes' : nichtOfflineHinweis)));
|
||||||
: nichtOfflineHinweis)));
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -143,7 +174,7 @@ class SongList extends StatelessWidget {
|
|||||||
title: const Text('Zu Wiedergabeliste hinzufügen'),
|
title: const Text('Zu Wiedergabeliste hinzufügen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_showAddToPlaylist(context, song);
|
_showAddToPlaylist(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -154,60 +185,57 @@ class SongList extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 categories = context.watch<CategoryService>();
|
||||||
final settings = context.watch<AppSettings>();
|
final settings = context.watch<AppSettings>();
|
||||||
return ListView.builder(
|
final cover = categories.coverFor(song,
|
||||||
itemCount: songs.length,
|
groupByCategory: settings.groupCoversByCategory);
|
||||||
itemBuilder: (context, i) {
|
return ListTile(
|
||||||
final s = songs[i];
|
leading: CoverImage(
|
||||||
final cover = categories.coverFor(s,
|
artUri: cover != null ? Uri.file(cover) : null,
|
||||||
groupByCategory: settings.groupCoversByCategory);
|
size: 48,
|
||||||
return ListTile(
|
radius: 6,
|
||||||
leading: CoverImage(
|
),
|
||||||
artUri: cover != null ? Uri.file(cover) : null,
|
title: Text(song.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
size: 48,
|
subtitle: Text(
|
||||||
radius: 6,
|
_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)),
|
onTap: () async {
|
||||||
maxLines: 1,
|
final handler = context.read<MeloAudioHandler>();
|
||||||
overflow: TextOverflow.ellipsis,
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
),
|
try {
|
||||||
trailing: Row(
|
final uebersprungen = await playSongs(
|
||||||
mainAxisSize: MainAxisSize.min,
|
handler,
|
||||||
children: [
|
warteschlange,
|
||||||
FavoriteButton(songId: s.id),
|
index,
|
||||||
IconButton(
|
coverOf: (s) => categories.coverFor(s,
|
||||||
tooltip: 'Mehr',
|
groupByCategory: settings.groupCoversByCategory),
|
||||||
icon: const Icon(Icons.more_vert),
|
);
|
||||||
onPressed: () => _showMenu(context, s),
|
if (uebersprungen > 0) {
|
||||||
),
|
messenger.showSnackBar(
|
||||||
],
|
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||||
),
|
);
|
||||||
onTap: () async {
|
}
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
} catch (e) {
|
||||||
try {
|
messenger.showSnackBar(
|
||||||
final uebersprungen = await playSongs(
|
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
|
||||||
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')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,15 +32,6 @@ void main() {
|
|||||||
expect(songs.first.title, 'Neu');
|
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 {
|
test('markMissing setzt Tombstone für nicht aktualisierte Dateien', () async {
|
||||||
final now1 = 100;
|
final now1 = 100;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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,
|
/// 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.
|
/// in den Favoriten (INNER JOIN auf songs) tauchte aber nie etwas auf.
|
||||||
void main() {
|
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) {
|
Future<void> pumpe(WidgetTester tester, MeloDb db, String songId) {
|
||||||
return tester.pumpWidget(
|
return tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
|
|||||||
Reference in New Issue
Block a user