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:
Hermes (Server)
2026-08-25 09:42:41 +02:00
parent 60c57577a3
commit 07acf77ba4
15 changed files with 511 additions and 202 deletions
+82 -25
View File
@@ -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<Song> songs;
@override
State<KuenstlerScreen> createState() => _KuenstlerScreenState();
}
class _KuenstlerScreenState extends State<KuenstlerScreen> {
/// 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<String> _auswahl = const {};
bool get _waehltAus => _auswahl.isNotEmpty;
void _wechsleAuswahl(String songId) =>
setState(() => _auswahl = auswahlNach(_auswahl, songId));
void _beendeAuswahl() => setState(() => _auswahl = const {});
List<Song> get _gewaehlteSongs =>
[for (final s in widget.songs) if (_auswahl.contains(s.id)) s];
Future<void> _inWiedergabeliste() async {
await fuegeZuWiedergabelisteHinzu(context, _gewaehlteSongs);
if (mounted) _beendeAuswahl();
}
Future<void> _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),
);
},
),
),
);
}
+1 -1
View File
@@ -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),
),
],
+21 -4
View File
@@ -92,6 +92,11 @@ class _SearchScreenState extends State<SearchScreen> {
if (mounted) setState(() => _verlauf = const []);
}
Future<void> _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<SearchScreen> {
begriffe: _verlauf,
onWaehlen: _uebernimm,
onLeeren: _verlaufLeeren,
onEntfernen: _verlaufEntferne,
)
: StreamBuilder<List<Song>>(
stream: _songs,
@@ -138,7 +144,7 @@ class _SearchScreenState extends State<SearchScreen> {
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<String> begriffe;
final ValueChanged<String> onWaehlen;
final VoidCallback onLeeren;
final ValueChanged<String> 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<Song> alleSongs;
void _oeffne(BuildContext context, String titel, List<Song> 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),
+1
View File
@@ -367,6 +367,7 @@ class SongZeile extends StatelessWidget {
onAuswahlWechsel?.call();
return;
}
FocusScope.of(context).unfocus();
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
try {
+17
View File
@@ -20,6 +20,15 @@ List<String> verlaufMit(List<String> verlauf, String begriff) {
return ergebnis.take(verlaufMaximum).toList();
}
/// [verlauf] ohne [begriff] — zum gezielten Entfernen eines einzelnen
/// Eintrags, ohne den ganzen Verlauf zu leeren.
List<String> verlaufOhne(List<String> 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<List<String>> entferne(String begriff) async {
final prefs = await SharedPreferences.getInstance();
final neu = verlaufOhne(prefs.getStringList(_key) ?? const [], begriff);
await prefs.setStringList(_key, neu);
return neu;
}
}