P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
419 lines
13 KiB
Dart
419 lines
13 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 []);
|
|
}
|
|
|
|
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,
|
|
)
|
|
: 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 const _Hinweis('Nichts gefunden');
|
|
}
|
|
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,
|
|
});
|
|
|
|
final List<String> begriffe;
|
|
final ValueChanged<String> onWaehlen;
|
|
final VoidCallback onLeeren;
|
|
|
|
@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),
|
|
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) {
|
|
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: () => 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)),
|
|
);
|
|
}
|
|
}
|
|
|