UX-Simulation: Mehrfachauswahl überall, Baka-Login, Bestätigungen, Suche
7 Agenten sind die App als normaler Nutzer durchgegangen (Erststart, Bibliothek, Download, Suche, Favoriten, Player, Einstellungen) und haben 39 Reibungspunkte gefunden. Erster Block: - Mehrfachauswahl lief nur im Lieder-/Favoriten-Reiter, nicht in Kategorie-/Künstler-/Album-Ansichten — jetzt überall, per geteilter auswahl_leiste.dart - Zwei unterschiedlich ausgestattete Favoriten-Listen (Tab vs. Playlisten) vereinheitlicht - Baka-Login zeigt Ladezustand + Fehler im Dialog statt bis zu 15s stumm zu bleiben (wie der Navidrome-Login) - Cache leeren, Navidrome-Abmelden, Download einzeln entfernen fragen jetzt nach - Download-Fehlermeldung bei toter Serververbindung nennt den echten Grund statt "War schon heruntergeladen" - YouTube-Fehlermeldungen sind jetzt rot statt neutralfarben - Suche schließt die Tastatur nach Auswahl, nennt den Suchbegriff bei "Nichts gefunden", einzelne Verlaufseinträge sind entfernbar - Icon-/Text-Korrekturen (Mikrofon-Icon fälschlich bei Speicherzugriff, "Online"-Tab existiert nicht, ReplayGain-Jargon) 537 Tests grün (vorher 533), flutter analyze ohne Befund.
This commit is contained in:
@@ -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<void> fuegeZuWiedergabelisteHinzu(
|
||||
BuildContext context, List<Song> gewaehlte) async {
|
||||
final db = context.read<MeloDb>();
|
||||
final dienst = context.read<PlaylistService>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final ziel = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: StreamBuilder<List<Playlist>>(
|
||||
stream: db.watchPlaylists(),
|
||||
builder: (context, snapshot) {
|
||||
final listen = snapshot.data ?? const <Playlist>[];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('Neue Wiedergabeliste'),
|
||||
onTap: () async {
|
||||
final neu = await showDialog<String?>(
|
||||
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<void> fuegeZuWarteschlangeHinzu(
|
||||
BuildContext context, List<Song> gewaehlte) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final categories = context.read<CategoryService>();
|
||||
final settings = context.read<AppSettings>();
|
||||
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'),
|
||||
));
|
||||
}
|
||||
@@ -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<SortableSongList> {
|
||||
[for (final s in sorted) if (_auswahl.contains(s.id)) s];
|
||||
|
||||
Future<void> _inWiedergabeliste(List<Song> gewaehlte) async {
|
||||
final db = context.read<MeloDb>();
|
||||
final dienst = context.read<PlaylistService>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final ziel = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: StreamBuilder<List<Playlist>>(
|
||||
stream: db.watchPlaylists(),
|
||||
builder: (context, snapshot) {
|
||||
final listen = snapshot.data ?? const <Playlist>[];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('Neue Wiedergabeliste'),
|
||||
onTap: () async {
|
||||
final neu = await showDialog<String?>(
|
||||
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<void> _inWarteschlange(List<Song> gewaehlte) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final categories = context.read<CategoryService>();
|
||||
final settings = context.read<AppSettings>();
|
||||
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<SortableSongList> {
|
||||
child: Column(
|
||||
children: [
|
||||
if (_waehltAus)
|
||||
_AuswahlLeiste(
|
||||
AuswahlLeiste(
|
||||
anzahl: _auswahl.length,
|
||||
onAbbrechen: _beendeAuswahl,
|
||||
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
|
||||
@@ -338,59 +255,6 @@ class _SortableSongListState extends State<SortableSongList> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user