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.
436 lines
14 KiB
Dart
436 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../playlists/playlist_detail_screen.dart';
|
|
import '../shared/theme.dart';
|
|
import '../shared/ueberschrift.dart';
|
|
import '../shared/titel_listen_screen.dart';
|
|
import 'category_service.dart';
|
|
import 'database.dart';
|
|
import 'song_list.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 {
|
|
const SearchScreen({super.key});
|
|
|
|
@override
|
|
State<SearchScreen> createState() => _SearchScreenState();
|
|
}
|
|
|
|
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 = '';
|
|
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 []);
|
|
}
|
|
|
|
Future<void> _verlaufEntferne(String begriff) async {
|
|
final neu = await SuchVerlauf.entferne(begriff);
|
|
if (mounted) setState(() => _verlauf = neu);
|
|
}
|
|
|
|
void _leeren() {
|
|
_entprellen?.cancel();
|
|
_feld.clear();
|
|
setState(() => _query = '');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final kategorien = context.watch<CategoryService>().alleNamen;
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
children: [
|
|
const Ueberschrift('Suchen'),
|
|
_Suchfeld(
|
|
controller: _feld,
|
|
onChanged: _tippt,
|
|
onAbschicken: _merke,
|
|
onLeeren: _leeren,
|
|
),
|
|
Expanded(
|
|
child: _query.isEmpty
|
|
? _Verlauf(
|
|
begriffe: _verlauf,
|
|
onWaehlen: _uebernimm,
|
|
onLeeren: _verlaufLeeren,
|
|
onEntfernen: _verlaufEntferne,
|
|
)
|
|
: 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 _Hinweis('Nichts gefunden für „$_query"');
|
|
}
|
|
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,
|
|
required this.onEntfernen,
|
|
});
|
|
|
|
final List<String> begriffe;
|
|
final ValueChanged<String> onWaehlen;
|
|
final VoidCallback onLeeren;
|
|
final ValueChanged<String> onEntfernen;
|
|
|
|
@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),
|
|
trailing: IconButton(
|
|
tooltip: '„$begriff" aus dem Verlauf entfernen',
|
|
icon: const Icon(Icons.close, size: 18),
|
|
onPressed: () => onEntfernen(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) {
|
|
FocusScope.of(context).unfocus();
|
|
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: () {
|
|
FocusScope.of(context).unfocus();
|
|
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)),
|
|
);
|
|
}
|
|
}
|
|
|