diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d67c8..b6f7444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🧑‍🤝‍🧑 UX-Simulation: Mehrfachauswahl, Baka-Login, Bestätigungen, Suche (2026-08-25) + +7 Agenten sind die App wie ein normaler Nutzer durchgegangen (Erststart, +Bibliothek, Download, Suche, Favoriten, Player, Einstellungen) und haben 39 +konkrete Reibungspunkte gefunden — verglichen mit Apple Music/Spotify. Erster +Block der Fixe: + +- 🖐️ **Mehrfachauswahl funktionierte nur im Lieder- und Favoriten-Reiter.** + Öffnete man eine Kategorie oder das Album eines Künstlers, tat langes + Drücken auf einen Titel gar nichts — derselbe Ripple-Effekt, aber ohne + Wirkung. Die Auswahl-Leiste und die Sammel-Aktionen ("Zur Warteschlange", + "Zu Wiedergabeliste") aus `SortableSongList` sind jetzt in + `lib/shared/auswahl_leiste.dart` geteilt und laufen auch in der + Album-/Kategorie-Detailansicht (`TitelListenScreen`, jetzt mit Sortierung + und Shuffle wie der Lieder-Reiter) und in der Künstlerseite + (`KuenstlerScreen`). +- 🔁 **Zwei "Favoriten"-Listen mit unterschiedlichem Funktionsumfang.** Der + Favoriten-Tab bot Sortieren/Shuffle/Auswahl, der über Playlisten + erreichbare zweite Favoriten-Bildschirm zeigte dieselben Songs nur nackt. + Beide zeigen jetzt dieselbe `SortableSongList`. +- 🔐 **Baka-Login blieb bei falschem Passwort bis zu 15 Sekunden lang + stumm.** Der Dialog schloss sich sofort beim Antippen von "Anmelden", + noch bevor die Anfrage überhaupt losging; der Fehler kam erst danach in + einer unauffälligen grauen Zeile. Jetzt bleibt der Dialog offen, zeigt + einen Ladespinner und den Fehler direkt darin — wie beim (bereits guten) + Navidrome-Login. +- 🗑️ **Cache leeren und Navidrome-Abmelden liefen ohne Rückfrage.** Ein + versehentlicher Tipp auf das kleine Papierkorb- bzw. Logout-Icon hat + sofort gewirkt. Beide fragen jetzt nach, das einzelne Entfernen eines + Downloads ebenso (analog zum bereits vorhandenen "Alle entfernen"-Dialog). +- 📡 **Downloads bei toter Serververbindung meldeten "War schon + heruntergeladen"** — dieselbe Meldung wie bei tatsächlich schon + vorhandenen Titeln. Jetzt erscheint der wirkliche Grund + ("Nicht mit dem Musikserver verbunden"). YouTube-Fehlermeldungen sind + außerdem jetzt rot statt in derselben Neutralfarbe wie Erfolgsmeldungen. +- 🔍 **Suche:** Die Tastatur blieb nach Auswahl eines Treffers offen und + verdeckte die Liste — schließt jetzt automatisch. "Nichts gefunden" nennt + jetzt den gesuchten Begriff. Einzelne Verlaufseinträge lassen sich + entfernen, ohne den ganzen Suchverlauf zu löschen. +- 🎤 **Zwei irreführende Icons:** "Musik-Berechtigung" in den Einstellungen + zeigte ein Mikrofon (fragt aber Speicherzugriff ab, kein Mikrofon); + "Musik erkennen" zeigte ein Fragezeichen-Symbol (Hilfe-Icon) statt eines + erkennbaren Mikrofon-Symbols. +- ✏️ Kleinere Textkorrekturen: der Hinweis "beim Baka-Konto anmelden" verwies + auf einen nicht existierenden Tab "Online" (richtig: Download-Tab); die + ReplayGain-Beschreibung nannte unerklärten Audio-Fachjargon. +- ✅ **Getestet:** 537 Tests grün (vorher 533, 4 neu für `verlaufOhne`), + `flutter analyze` ohne Befund. + ### 🎨 Cover-Farbe, Teilen, Android Auto, geschärfte Typografie (2026-08-24) Die P3-Liste aus dem Review. diff --git a/lib/downloads/downloads_liste_screen.dart b/lib/downloads/downloads_liste_screen.dart index e8e6499..f472143 100644 --- a/lib/downloads/downloads_liste_screen.dart +++ b/lib/downloads/downloads_liste_screen.dart @@ -50,6 +50,34 @@ class DownloadsListeScreen extends StatelessWidget { ); } + Future _einzelnEntfernen(BuildContext context, Download eintrag) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ja = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Vom Gerät nehmen?'), + content: Text('„${eintrag.titel}" wird vom Gerät genommen. ' + 'Am Server bleibt der Titel unverändert.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Entfernen'), + ), + ], + ), + ); + if (ja != true) return; + await dienst.entferne(eintrag.navidromeId); + messenger.showSnackBar( + SnackBar(content: Text('„${eintrag.titel}" entfernt')), + ); + } + @override Widget build(BuildContext context) { final db = context.read(); @@ -102,9 +130,7 @@ class DownloadsListeScreen extends StatelessWidget { trailing: IconButton( tooltip: 'Vom Gerät nehmen', icon: const Icon(Icons.delete_outline), - onPressed: () => context - .read() - .entferne(eintrag.navidromeId), + onPressed: () => _einzelnEntfernen(context, eintrag), ), ); }, diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index a9bb54e..96551e0 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -464,7 +464,12 @@ class _LadeKnopfState extends State<_LadeKnopf> { if (dienst.fehlgeschlagen > 0) { teile.add('${dienst.fehlgeschlagen} fehlgeschlagen'); } - if (teile.isEmpty) return 'War schon heruntergeladen'; + if (teile.isEmpty) { + // Kein Titel, kein Fehlschlag: entweder war wirklich schon alles da, + // oder der Download ist gar nicht erst gestartet (z.B. keine + // Verbindung zum Server) — dann steckt der Grund in dienst.fehler. + return dienst.fehler ?? 'War schon heruntergeladen'; + } return teile.join(' · '); } @@ -634,15 +639,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { } Future _anmelden() async { - final auth = context.read(); - final daten = await showDialog<(String, String)>( + await showDialog( context: context, builder: (_) => const _AnmeldeDialog(), ); - if (daten == null || !mounted) return; - final fehler = await auth.anmelden(daten.$1, daten.$2); - if (!mounted) return; - setState(() => _meldung = fehler); } Future _herunterladen() async { @@ -785,7 +785,11 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { if (_meldung != null) ...[ const SizedBox(height: 16), Text(_meldung!, - style: const TextStyle(color: MeloTheme.text2, fontSize: 13)), + style: TextStyle( + color: _meldung!.startsWith('✅') + ? MeloTheme.text2 + : MeloTheme.red, + fontSize: 13)), ], ], ); @@ -804,6 +808,8 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { final _benutzer = TextEditingController(text: 'Baka'); final _passwort = TextEditingController(); bool _sichtbar = false; + bool _verbindet = false; + String? _fehler; @override void dispose() { @@ -812,6 +818,24 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { super.dispose(); } + Future _anmelden() async { + setState(() { + _verbindet = true; + _fehler = null; + }); + final auth = context.read(); + final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text); + if (!mounted) return; + if (fehler == null) { + Navigator.pop(context); + return; + } + setState(() { + _verbindet = false; + _fehler = fehler; + }); + } + @override Widget build(BuildContext context) { return AlertDialog( @@ -821,10 +845,12 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { children: [ TextField( controller: _benutzer, + enabled: !_verbindet, decoration: const InputDecoration(labelText: 'Benutzername'), ), TextField( controller: _passwort, + enabled: !_verbindet, obscureText: !_sichtbar, decoration: InputDecoration( labelText: 'Passwort', @@ -834,16 +860,30 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { ), ), ), + if (_fehler != null) ...[ + const SizedBox(height: 8), + Text(_fehler!, + style: const TextStyle(color: MeloTheme.red, fontSize: 12)), + ], + if (_verbindet) + const Padding( + padding: EdgeInsets.only(top: 12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: MeloTheme.red), + ), + ), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: _verbindet ? null : () => Navigator.pop(context), child: const Text('Abbrechen'), ), FilledButton( - onPressed: () => Navigator.pop( - context, (_benutzer.text.trim(), _passwort.text)), + onPressed: _verbindet ? null : _anmelden, child: const Text('Anmelden'), ), ], diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index 8fa91df..a69ba7b 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../settings/app_settings.dart'; +import '../shared/auswahl.dart'; +import '../shared/auswahl_leiste.dart'; import '../shared/cover.dart'; import '../shared/theme.dart'; import '../shared/titel_listen_screen.dart'; @@ -92,45 +94,100 @@ String kuenstlerUntertitel(int titel, int alben) => /// /// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem /// Künstler mit acht Alben stand alles ungeordnet untereinander. -class KuenstlerScreen extends StatelessWidget { +class KuenstlerScreen extends StatefulWidget { const KuenstlerScreen({super.key, required this.name, required this.songs}); final String name; final List songs; + @override + State createState() => _KuenstlerScreenState(); +} + +class _KuenstlerScreenState extends State { + /// Song-IDs der gerade ausgewählten Titel in „Alle Titel" — leer heißt: + /// kein Auswahl-Modus. Wie in SortableSongList, damit langes Drücken auf + /// einer Songzeile überall in der App dasselbe tut. + Set _auswahl = const {}; + + bool get _waehltAus => _auswahl.isNotEmpty; + + void _wechsleAuswahl(String songId) => + setState(() => _auswahl = auswahlNach(_auswahl, songId)); + + void _beendeAuswahl() => setState(() => _auswahl = const {}); + + List get _gewaehlteSongs => + [for (final s in widget.songs) if (_auswahl.contains(s.id)) s]; + + Future _inWiedergabeliste() async { + await fuegeZuWiedergabelisteHinzu(context, _gewaehlteSongs); + if (mounted) _beendeAuswahl(); + } + + Future _inWarteschlange() async { + await fuegeZuWarteschlangeHinzu(context, _gewaehlteSongs); + if (mounted) _beendeAuswahl(); + } + @override Widget build(BuildContext context) { - final alben = groupByAlbum(songs); + final alben = groupByAlbum(widget.songs); // Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr. final zeigeAlben = alben.length > 1; final albumListe = zeigeAlben ? alben.entries.toList() : const []; // Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel" final vorspann = zeigeAlben ? albumListe.length + 3 : 0; - // Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile - // hört auf den Player und öffnet für ihr Herz eine eigene Abfrage. Bei - // einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen der Seite. - return Scaffold( - appBar: AppBar(title: Text(name)), - body: ListView.builder( - itemCount: vorspann + songs.length, - itemBuilder: (context, i) { - if (zeigeAlben) { - if (i == 0) return const _Kopf('Alben'); - if (i <= albumListe.length) { - final eintrag = albumListe[i - 1]; - return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + return PopScope( + // Zurück beendet zuerst die Auswahl, wie in SortableSongList — sonst + // verlöre man die Auswahl mit derselben Geste, die überall sonst + // „einen Schritt zurück" bedeutet. + canPop: !_waehltAus, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _waehltAus) _beendeAuswahl(); + }, + child: Scaffold( + appBar: _waehltAus + ? AppBar( + automaticallyImplyLeading: false, + titleSpacing: 0, + title: AuswahlLeiste( + anzahl: _auswahl.length, + onAbbrechen: _beendeAuswahl, + onWiedergabeliste: _inWiedergabeliste, + onWarteschlange: _inWarteschlange, + ), + ) + : AppBar(title: Text(widget.name)), + // Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile + // hört auf den Player und öffnet für ihr Herz eine eigene Abfrage. + // Bei einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen + // der Seite. + body: ListView.builder( + itemCount: vorspann + widget.songs.length, + itemBuilder: (context, i) { + if (zeigeAlben) { + if (i == 0) return const _Kopf('Alben'); + if (i <= albumListe.length) { + final eintrag = albumListe[i - 1]; + return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + } + if (i == albumListe.length + 1) return const Divider(height: 1); + if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); } - if (i == albumListe.length + 1) return const Divider(height: 1); - if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); - } - final index = i - vorspann; - return SongZeile( - song: songs[index], - warteschlange: songs, - index: index, - ); - }, + final index = i - vorspann; + final song = widget.songs[index]; + return SongZeile( + song: song, + warteschlange: widget.songs, + index: index, + auswahlModus: _waehltAus, + ausgewaehlt: _auswahl.contains(song.id), + onAuswahlWechsel: () => _wechsleAuswahl(song.id), + ); + }, + ), ), ); } diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index 411fd8a..156584c 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -163,7 +163,7 @@ class _Header extends StatelessWidget { ), IconButton( tooltip: 'Musik erkennen', - icon: const Icon(Icons.help_outline), + icon: const Icon(Icons.mic_none), onPressed: () => MusicRecognitionSheet.show(context), ), ], diff --git a/lib/library/search_screen.dart b/lib/library/search_screen.dart index 7c3add2..1d54cf0 100644 --- a/lib/library/search_screen.dart +++ b/lib/library/search_screen.dart @@ -92,6 +92,11 @@ class _SearchScreenState extends State { if (mounted) setState(() => _verlauf = const []); } + Future _verlaufEntferne(String begriff) async { + final neu = await SuchVerlauf.entferne(begriff); + if (mounted) setState(() => _verlauf = neu); + } + void _leeren() { _entprellen?.cancel(); _feld.clear(); @@ -118,6 +123,7 @@ class _SearchScreenState extends State { begriffe: _verlauf, onWaehlen: _uebernimm, onLeeren: _verlaufLeeren, + onEntfernen: _verlaufEntferne, ) : StreamBuilder>( stream: _songs, @@ -138,7 +144,7 @@ class _SearchScreenState extends State { query: _query, ); if (treffer.istLeer) { - return const _Hinweis('Nichts gefunden'); + return _Hinweis('Nichts gefunden für „$_query"'); } return _Ergebnisse( treffer: treffer, @@ -228,11 +234,13 @@ class _Verlauf extends StatelessWidget { required this.begriffe, required this.onWaehlen, required this.onLeeren, + required this.onEntfernen, }); final List begriffe; final ValueChanged onWaehlen; final VoidCallback onLeeren; + final ValueChanged onEntfernen; @override Widget build(BuildContext context) { @@ -252,6 +260,11 @@ class _Verlauf extends StatelessWidget { ListTile( leading: const Icon(Icons.history), title: Text(begriff), + trailing: IconButton( + tooltip: '„$begriff" aus dem Verlauf entfernen', + icon: const Icon(Icons.close, size: 18), + onPressed: () => onEntfernen(begriff), + ), onTap: () => onWaehlen(begriff), ), ], @@ -270,6 +283,7 @@ class _Ergebnisse extends StatelessWidget { final List alleSongs; void _oeffne(BuildContext context, String titel, List songs) { + FocusScope.of(context).unfocus(); Navigator.of(context).push(MaterialPageRoute( builder: (_) => TitelListenScreen(titel: titel, songs: songs), )); @@ -334,9 +348,12 @@ class _Ergebnisse extends StatelessWidget { 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), - )), + onTap: () { + FocusScope.of(context).unfocus(); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PlaylistDetailScreen(playlist: liste), + )); + }, ), ], const SizedBox(height: MeloSpace.md), diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index d970a0b..48d30c8 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -367,6 +367,7 @@ class SongZeile extends StatelessWidget { onAuswahlWechsel?.call(); return; } + FocusScope.of(context).unfocus(); final handler = context.read(); final messenger = ScaffoldMessenger.of(context); try { diff --git a/lib/library/such_verlauf.dart b/lib/library/such_verlauf.dart index bc9ca49..6ddd01a 100644 --- a/lib/library/such_verlauf.dart +++ b/lib/library/such_verlauf.dart @@ -20,6 +20,15 @@ List verlaufMit(List verlauf, String begriff) { return ergebnis.take(verlaufMaximum).toList(); } +/// [verlauf] ohne [begriff] — zum gezielten Entfernen eines einzelnen +/// Eintrags, ohne den ganzen Verlauf zu leeren. +List verlaufOhne(List verlauf, String begriff) { + return [ + for (final alt in verlauf) + if (alt.toLowerCase() != begriff.trim().toLowerCase()) alt, + ]; +} + /// Merkt sich die letzten Suchbegriffe über einen App-Neustart hinweg. /// Aufbau wie [SortStore]: statische Methoden auf SharedPreferences. class SuchVerlauf { @@ -42,4 +51,12 @@ class SuchVerlauf { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_key); } + + /// Entfernt [begriff] aus dem Verlauf und gibt den neuen Verlauf zurück. + static Future> entferne(String begriff) async { + final prefs = await SharedPreferences.getInstance(); + final neu = verlaufOhne(prefs.getStringList(_key) ?? const [], begriff); + await prefs.setStringList(_key, neu); + return neu; + } } diff --git a/lib/playlists/playlists_screen.dart b/lib/playlists/playlists_screen.dart index 4bb5184..d7fedb3 100644 --- a/lib/playlists/playlists_screen.dart +++ b/lib/playlists/playlists_screen.dart @@ -3,7 +3,8 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/playlist_service.dart'; -import '../library/song_list.dart'; +import '../shared/sort_store.dart'; +import '../shared/sortable_song_list.dart'; import '../shared/theme.dart'; import 'create_playlist_dialog.dart'; import 'playlist_detail_screen.dart'; @@ -146,6 +147,10 @@ class _Empty extends StatelessWidget { } /// Gepushte Ansicht der favorisierten Songs. +/// +/// Dieselbe [SortableSongList] wie im Favoriten-Tab (Shuffle, Sortieren, +/// Mehrfachauswahl) — vorher stand hier eine nackte [SongList] ohne all das, +/// obwohl es dieselben Songs sind wie im Tab. class FavoritesListScreen extends StatelessWidget { const FavoritesListScreen({super.key}); @@ -157,13 +162,15 @@ class FavoritesListScreen extends StatelessWidget { body: StreamBuilder>( stream: db.watchFavorites(), builder: (context, snapshot) { - final songs = snapshot.data ?? const []; - if (songs.isEmpty) { - return const Center( - child: Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)), - ); - } - return SongList(songs); + final songs = snapshot.data ?? const []; + return SortableSongList( + songs: songs, + storeKey: SortStore.favoriten, + empty: const Center( + child: Text('Noch keine Favoriten', + style: TextStyle(color: MeloTheme.text2)), + ), + ); }, ), ); diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index d211c06..3733d49 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -133,7 +133,8 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.equalizer), title: const Text('Gleiche Lautstärke'), subtitle: const Text( - 'Senkt laute Titel ab (nur bei Dateien mit ReplayGain-Tag)'), + 'Senkt laute Titel automatisch ab (wirkt nur bei Titeln ' + 'mit Lautstärke-Metadaten)'), value: settings.normalizeVolume, onChanged: settings.setNormalizeVolume, ), @@ -281,7 +282,7 @@ class _SettingsScreenState extends State { const Divider(height: 1), const _SectionLabel('Berechtigungen'), ListTile( - leading: const Icon(Icons.mic_none), + leading: const Icon(Icons.perm_media_outlined), title: const Text('Musik-Berechtigung'), trailing: TextButton( onPressed: openMusicPermissionSettings, @@ -457,11 +458,51 @@ class _SettingsScreenState extends State { } Future _trennNavidrome() async { + final ja = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Von Navidrome abmelden?'), + content: const Text( + 'Die gespeicherten Zugangsdaten werden gelöscht. Für die ' + 'erneute Anmeldung brauchst du Server-Adresse, Benutzername ' + 'und Passwort wieder.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Abmelden'), + ), + ], + ), + ); + if (ja != true) return; await _navidrome.loescheZugangsdaten(); if (mounted) setState(() {}); } Future _clearCache() async { + final ja = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Zwischenspeicher leeren?'), + content: Text('${_formatBytes(_cacheSize)} werden gelöscht. ' + 'Beim nächsten Abspielen lädt Melo diese Titel erneut.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Leeren'), + ), + ], + ), + ); + if (ja != true) return; await _cache.clearCache(); if (mounted) { setState(() => _cacheSize = 0); @@ -558,7 +599,7 @@ class _CloudSyncBereich extends StatelessWidget { leading: Icon(Icons.cloud_off), title: Text('Nicht angemeldet'), subtitle: Text( - 'Für den Abgleich im Tab „Online“ beim Baka-Konto anmelden'), + 'Für den Abgleich im Download-Tab beim Baka-Konto anmelden'), ); } diff --git a/lib/shared/auswahl_leiste.dart b/lib/shared/auswahl_leiste.dart new file mode 100644 index 0000000..09b2fd7 --- /dev/null +++ b/lib/shared/auswahl_leiste.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../library/category_service.dart'; +import '../library/playlist_service.dart'; +import '../library/song_media.dart'; +import '../player/audio_handler.dart'; +import '../playlists/create_playlist_dialog.dart'; +import '../settings/app_settings.dart'; +import 'auswahl.dart'; +import 'theme.dart'; + +/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen. +/// +/// Gemeinsam für alle Songlisten, die Mehrfachauswahl anbieten — sie ersetzt +/// die sonst dort stehende Zeile, statt darunter zu erscheinen. +class AuswahlLeiste extends StatelessWidget { + const AuswahlLeiste({ + super.key, + required this.anzahl, + required this.onAbbrechen, + required this.onWiedergabeliste, + required this.onWarteschlange, + }); + + final int anzahl; + final VoidCallback onAbbrechen; + final VoidCallback onWiedergabeliste; + final VoidCallback onWarteschlange; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs), + child: Row( + children: [ + IconButton( + tooltip: 'Auswahl beenden', + icon: const Icon(Icons.close), + onPressed: onAbbrechen, + ), + Expanded( + child: Text( + auswahlText(anzahl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + IconButton( + tooltip: 'Zur Warteschlange hinzufügen', + icon: const Icon(Icons.queue), + onPressed: onWarteschlange, + ), + IconButton( + tooltip: 'Zu Wiedergabeliste hinzufügen', + icon: const Icon(Icons.playlist_add), + onPressed: onWiedergabeliste, + ), + ], + ), + ); + } +} + +/// Legt [gewaehlte] in eine (neue oder bestehende) Wiedergabeliste, mit +/// Rückmeldung über Neues/schon-Vorhandenes/Fehler. +Future fuegeZuWiedergabelisteHinzu( + BuildContext context, List gewaehlte) async { + final db = context.read(); + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ziel = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: StreamBuilder>( + stream: db.watchPlaylists(), + builder: (context, snapshot) { + final listen = snapshot.data ?? const []; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.add), + title: const Text('Neue Wiedergabeliste'), + onTap: () async { + final neu = await showDialog( + context: sheetContext, + builder: (_) => const CreatePlaylistDialog(), + ); + if (neu != null && sheetContext.mounted) { + Navigator.pop(sheetContext, neu); + } + }, + ), + for (final liste in listen) + ListTile( + leading: const Icon(Icons.playlist_play), + title: Text(liste.name), + onTap: () => Navigator.pop(sheetContext, liste.id), + ), + ], + ); + }, + ), + ), + ); + if (ziel == null) return; + final vorhanden = await db.watchPlaylistSongs(ziel).first; + // Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn + // wegen des Primärschlüssels nur verschieben und Lücken hinterlassen. + final bekannt = {for (final s in vorhanden) s.id}; + var position = vorhanden.length; + var neu = 0; + String? fehler; + for (final song in gewaehlte) { + if (bekannt.contains(song.id)) continue; + try { + await dienst.addSongToPlaylist(ziel, song.id, position++); + neu++; + } catch (e) { + fehler = '$e'; + break; + } + } + if (!context.mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(playlistMeldung( + neu: neu, + schonDa: gewaehlte.length - neu, + fehler: fehler, + )), + )); +} + +/// Hängt [gewaehlte] ans Ende der Warteschlange, mit Rückmeldung, falls +/// nicht alle Titel angehängt werden konnten (z. B. offline). +Future fuegeZuWarteschlangeHinzu( + BuildContext context, List gewaehlte) async { + final handler = context.read(); + final categories = context.read(); + final settings = context.read(); + final messenger = ScaffoldMessenger.of(context); + var angehaengt = 0; + for (final song in gewaehlte) { + final ok = await handler.addToQueue(songToMediaItem( + song, + cover: categories.coverFor(song, + groupByCategory: settings.groupCoversByCategory), + )); + if (ok) angehaengt++; + } + if (!context.mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(angehaengt == gewaehlte.length + ? '$angehaengt Titel in der Warteschlange' + : '$angehaengt von ${gewaehlte.length} Titeln angehängt'), + )); +} diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart index fb67828..f764173 100644 --- a/lib/shared/sortable_song_list.dart +++ b/lib/shared/sortable_song_list.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../library/category_service.dart'; import '../library/database.dart'; import '../library/song_list.dart'; import '../library/song_media.dart'; -import '../library/category_service.dart'; -import '../library/playlist_service.dart'; import '../library/song_sort.dart'; import '../player/audio_handler.dart'; import '../settings/app_settings.dart'; -import '../playlists/create_playlist_dialog.dart'; import 'alphabet_leiste.dart'; import 'auswahl.dart'; +import 'auswahl_leiste.dart'; import 'sort_store.dart'; import 'theme.dart'; @@ -59,95 +58,13 @@ class _SortableSongListState extends State { [for (final s in sorted) if (_auswahl.contains(s.id)) s]; Future _inWiedergabeliste(List gewaehlte) async { - final db = context.read(); - final dienst = context.read(); - final messenger = ScaffoldMessenger.of(context); - final ziel = await showModalBottomSheet( - context: context, - builder: (sheetContext) => SafeArea( - child: StreamBuilder>( - stream: db.watchPlaylists(), - builder: (context, snapshot) { - final listen = snapshot.data ?? const []; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.add), - title: const Text('Neue Wiedergabeliste'), - onTap: () async { - final neu = await showDialog( - context: sheetContext, - builder: (_) => const CreatePlaylistDialog(), - ); - if (neu != null && sheetContext.mounted) { - Navigator.pop(sheetContext, neu); - } - }, - ), - for (final liste in listen) - ListTile( - leading: const Icon(Icons.playlist_play), - title: Text(liste.name), - onTap: () => Navigator.pop(sheetContext, liste.id), - ), - ], - ); - }, - ), - ), - ); - if (ziel == null) return; - final vorhanden = await db.watchPlaylistSongs(ziel).first; - // Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn - // wegen des Primärschlüssels nur verschieben und Lücken hinterlassen. - final bekannt = {for (final s in vorhanden) s.id}; - var position = vorhanden.length; - var neu = 0; - String? fehler; - for (final song in gewaehlte) { - if (bekannt.contains(song.id)) continue; - try { - await dienst.addSongToPlaylist(ziel, song.id, position++); - neu++; - } catch (e) { - fehler = '$e'; - break; - } - } - if (!mounted) return; - _beendeAuswahl(); - messenger.showSnackBar(SnackBar( - content: Text(playlistMeldung( - neu: neu, - schonDa: gewaehlte.length - neu, - fehler: fehler, - )), - )); + await fuegeZuWiedergabelisteHinzu(context, gewaehlte); + if (mounted) _beendeAuswahl(); } - Future _inWarteschlange(List gewaehlte) async { - final handler = context.read(); - final categories = context.read(); - final settings = context.read(); - final messenger = ScaffoldMessenger.of(context); - var angehaengt = 0; - for (final song in gewaehlte) { - final ok = await handler.addToQueue(songToMediaItem( - song, - cover: categories.coverFor(song, - groupByCategory: settings.groupCoversByCategory), - )); - if (ok) angehaengt++; - } - if (!mounted) return; - _beendeAuswahl(); - messenger.showSnackBar(SnackBar( - content: Text(angehaengt == gewaehlte.length - ? '$angehaengt Titel in der Warteschlange' - : '$angehaengt von ${gewaehlte.length} Titeln angehängt'), - )); + await fuegeZuWarteschlangeHinzu(context, gewaehlte); + if (mounted) _beendeAuswahl(); } @override @@ -274,7 +191,7 @@ class _SortableSongListState extends State { child: Column( children: [ if (_waehltAus) - _AuswahlLeiste( + AuswahlLeiste( anzahl: _auswahl.length, onAbbrechen: _beendeAuswahl, onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)), @@ -338,59 +255,6 @@ class _SortableSongListState extends State { } } -/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen. -/// -/// Sie ersetzt die Shuffle-Zeile, statt darunter zu erscheinen — sonst -/// rutschte die Liste beim Auswählen nach unten weg. -class _AuswahlLeiste extends StatelessWidget { - const _AuswahlLeiste({ - required this.anzahl, - required this.onAbbrechen, - required this.onWiedergabeliste, - required this.onWarteschlange, - }); - - final int anzahl; - final VoidCallback onAbbrechen; - final VoidCallback onWiedergabeliste; - final VoidCallback onWarteschlange; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB( - MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs), - child: Row( - children: [ - IconButton( - tooltip: 'Auswahl beenden', - icon: const Icon(Icons.close), - onPressed: onAbbrechen, - ), - Expanded( - child: Text( - auswahlText(anzahl), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - IconButton( - tooltip: 'Zur Warteschlange hinzufügen', - icon: const Icon(Icons.queue), - onPressed: onWarteschlange, - ), - IconButton( - tooltip: 'Zu Wiedergabeliste hinzufügen', - icon: const Icon(Icons.playlist_add), - onPressed: onWiedergabeliste, - ), - ], - ), - ); - } -} - /// Bottom-Sheet zur Wahl von Sortier-Kriterium und Richtung. class _SortSheet extends StatefulWidget { const _SortSheet({required this.current}); diff --git a/lib/shared/titel_listen_screen.dart b/lib/shared/titel_listen_screen.dart index 10182aa..211587f 100644 --- a/lib/shared/titel_listen_screen.dart +++ b/lib/shared/titel_listen_screen.dart @@ -1,13 +1,18 @@ import 'package:flutter/material.dart'; import '../library/database.dart'; -import '../library/song_list.dart'; +import 'sortable_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. +/// +/// [SortableSongList] statt der nackten [SongList]: sonst bot diese Ansicht +/// weder Sortieren noch Mehrfachauswahl an, obwohl dieselbe Songzeile im +/// Lieder-Reiter beides kann — für den Nutzer wirkte langes Drücken hier wie +/// ein stiller Fehlschlag. class TitelListenScreen extends StatelessWidget { const TitelListenScreen({ super.key, @@ -22,7 +27,7 @@ class TitelListenScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(titel)), - body: SongList(songs), + body: SortableSongList(songs: songs, storeKey: 'titel_liste'), ); } } diff --git a/test/library/search_screen_test.dart b/test/library/search_screen_test.dart index 55eaf21..26f778a 100644 --- a/test/library/search_screen_test.dart +++ b/test/library/search_screen_test.dart @@ -127,11 +127,12 @@ void main() { await aufraeumen(tester); }); - testWidgets('ohne Treffer erscheint "Nichts gefunden"', (tester) async { + testWidgets('ohne Treffer erscheint "Nichts gefunden" mit dem Suchbegriff', + (tester) async { await pumpe(tester); await tippe(tester, 'jazzfrühstück'); - expect(find.text('Nichts gefunden'), findsOneWidget); + expect(find.text('Nichts gefunden für „jazzfrühstück"'), findsOneWidget); await aufraeumen(tester); }); diff --git a/test/library/such_verlauf_test.dart b/test/library/such_verlauf_test.dart index 60d1cb3..89c59a3 100644 --- a/test/library/such_verlauf_test.dart +++ b/test/library/such_verlauf_test.dart @@ -44,4 +44,27 @@ void main() { expect(vorher, ['rock']); }); }); + + group('verlaufOhne', () { + test('entfernt genau den gesuchten Begriff', () { + expect(verlaufOhne(['trap', 'neoni', 'rock'], 'neoni'), + ['trap', 'rock']); + }); + + test('Groß- und Kleinschreibung gilt als derselbe Begriff', () { + expect(verlaufOhne(['Rock', 'trap'], 'rock'), ['trap']); + }); + + test('ein nicht enthaltener Begriff ändert nichts', () { + expect(verlaufOhne(['rock'], 'trap'), ['rock']); + }); + + test('lässt die Ausgangsliste unverändert', () { + final vorher = ['rock', 'trap']; + + verlaufOhne(vorher, 'rock'); + + expect(vorher, ['rock', 'trap']); + }); + }); }