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/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 createState() => _SearchScreenState(); } class _SearchScreenState extends State { 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> _songs = context.read().watchSongs(); late final Stream> _listen = context.read().watchPlaylists(); String _query = ''; List _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 _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 _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().alleNamen; return SafeArea( bottom: false, child: Column( children: [ const Padding( padding: EdgeInsets.fromLTRB(20, 16, 20, 4), child: Align( alignment: Alignment.centerLeft, child: Text('Suchen', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), ), ), _Suchfeld( controller: _feld, onChanged: _tippt, onAbschicken: _merke, onLeeren: _leeren, ), Expanded( child: _query.isEmpty ? _Verlauf( begriffe: _verlauf, onWaehlen: _uebernimm, onLeeren: _verlaufLeeren, ) : StreamBuilder>( stream: _songs, builder: (context, songSnap) { if (songSnap.hasError) { return _Hinweis('Fehler: ${songSnap.error}'); } return StreamBuilder>( 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 onChanged; final ValueChanged 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( 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 begriffe; final ValueChanged 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 alleSongs; void _oeffne(BuildContext context, String titel, List songs) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => TitelListenScreen(titel: titel, songs: songs), )); } @override Widget build(BuildContext context) { final dienst = context.watch(); // 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 songs; final void Function(BuildContext, String, List) 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)), ); } }