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:
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user