This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/screens/home_screen.dart
T
Hermes (Server) 8b6a04ead8 Fix: Review-Findings korrigiert (Tag-Leiste, Profil, Singleton u.a.)
CRIT:
- Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats
- Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr)
HIGH:
- CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel)
- StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt)
- CloudService als Singleton (konsistenter Token-Zustand in allen Services)
MED/LOW:
- Playlist-Erkennung nur noch via list= Parameter
- Player: fehlende Quelle wird geloggt statt still
- song_tile: null-ID-Guard vor Tag-Dialog
- Scanner-Log mit Exception-Objekt
- FavoritenService: anzahlFavoriten() fuer StatistikCard
2026-07-31 15:42:54 +02:00

870 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../database/db_helper.dart';
import '../viewmodels/melo_home_viewmodel.dart';
import '../models/song.dart';
import '../models/playlist.dart';
import '../utils/farb_theme.dart';
import '../utils/user_effekte.dart';
import '../services/cloud_service.dart';
import '../services/favoriten_service.dart';
import '../widgets/mini_player.dart';
import '../widgets/melo_header.dart';
import '../widgets/statistik_card.dart';
import '../widgets/recent_widget.dart';
import '../widgets/tag_stats_widget.dart';
import '../widgets/tag_leiste.dart';
import '../widgets/song_tile.dart';
import '../widgets/navidrome_browser.dart';
import '../widgets/playlist_sheet.dart';
import 'download_screen.dart';
import 'cloud_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:permission_handler/permission_handler.dart';
import '../widgets/cloud_einstellungen.dart';
import '../config/app_config.dart';
class MeloHome extends StatefulWidget {
const MeloHome({super.key});
@override
State<MeloHome> createState() => _MeloHomeState();
}
class _MeloHomeState extends State<MeloHome> {
final MeloHomeViewModel _vm = MeloHomeViewModel();
final CloudService _cloud = CloudService();
int _aktiverTab = 0;
String _nutzer = 'Baka'; // Aktueller Nutzer
final Future<int> _favoritenZahl = FavoritenService().anzahlFavoriten();
@override
void initState() {
super.initState();
_ladeNutzer();
_vm.addListener(() => setState(() {}));
_vm.ladeSongs();
}
Future<void> _ladeNutzer() async {
final p = await SharedPreferences.getInstance();
final n = p.getString('melo_nutzer') ?? '';
if (n.isNotEmpty && mounted) {
setState(() => _nutzer = n);
_vm.setzeNutzer(n);
}
// Erster Start? → einmalig Modus wählen (Cloud oder nur lokal) Login ist freiwillig
if (!p.containsKey('melo_modus')) {
await _zeigeModusWahl();
return;
}
// Login-Effekt beim App-Start: nur wenn ein Token vorhanden ist (eingeloggt)
final ok = await CloudService().restoreLogin();
if (ok && mounted) {
UserEffekt.anwenden(_nutzer);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(UserEffekt.fuer(_nutzer).begruessung),
duration: const Duration(seconds: 3),
));
}
}
/// Einmalige Auswahl beim ersten App-Start: Cloud-Sync oder nur lokal.
Future<void> _zeigeModusWahl() async {
if (!mounted) return;
final cloud = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('👋 Willkommen bei Melo!',
style: TextStyle(color: Colors.white, fontSize: 16)),
content: const Text(
'Wie möchtest du Melo nutzen?',
style: TextStyle(color: Colors.white, fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('📱 Nur lokal',
style: TextStyle(color: Colors.white)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('☁️ Cloud (empfohlen)',
style: TextStyle(color: MeloTheme.rot, fontWeight: FontWeight.bold)),
),
],
),
);
final p = await SharedPreferences.getInstance();
await p.setString('melo_modus', cloud == true ? 'cloud' : 'lokal');
// Cloud gewählt → direkt zum Login-Dialog (Name + Passwort)
if (cloud == true && mounted) {
_nutzerWechseln();
}
}
Future<void> _zeigeProfil() async {
final p = await SharedPreferences.getInstance();
final modus = p.getString('melo_modus') ?? 'cloud';
// Cloud-Count VOR dem Dialog auflösen (sonst stünde "Instance of Future" da)
final cloudCount = await _cloud.status();
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Row(children: [
const Icon(Icons.person, color: MeloTheme.rot, size: 22),
const SizedBox(width: 8),
Text(_nutzer, style: const TextStyle(color: Colors.white, fontSize: 17)),
]),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_profilZeile(Icons.music_note, 'Lieder auf Gerät', '${_vm.songs.length}'),
_profilZeile(Icons.cloud, 'Cloud-Server', '${cloudCount?['total'] ?? 0}'),
const Divider(color: MeloTheme.dunkel2),
// Später von "nur lokal" auf Cloud wechseln jederzeit möglich
if (modus == 'lokal') ...[
ListTile(
leading: const Icon(Icons.cloud_upload, color: Colors.blueAccent, size: 18),
title: const Text('☁️ Cloud aktivieren',
style: TextStyle(color: Colors.white, fontSize: 13)),
subtitle: const Text('Musik sichern & geräteübergreifend nutzen',
style: TextStyle(color: Colors.grey, fontSize: 11)),
onTap: () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('melo_modus', 'cloud');
if (ctx.mounted) Navigator.pop(ctx);
_nutzerWechseln();
},
),
const Divider(color: MeloTheme.dunkel2),
],
ListTile(
leading: const Icon(Icons.swap_horiz, color: Colors.grey, size: 18),
title: const Text('Nutzer wechseln', style: TextStyle(color: Colors.white, fontSize: 13)),
onTap: () {
Navigator.pop(ctx);
_nutzerWechseln();
},
),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red, size: 18),
title: const Text('Abmelden', style: TextStyle(color: Colors.red, fontSize: 13)),
onTap: () async {
// Token wirklich löschen, sonst wäre man gar nicht abgemeldet
await CloudService().logout();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('melo_nutzer');
if (ctx.mounted) Navigator.pop(ctx);
if (mounted) setState(() => _nutzer = '');
},
),
],
),
),
);
}
Widget _profilZeile(IconData icon, String label, String wert) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
Icon(icon, color: MeloTheme.rot, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12))),
Text(wert, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
]),
);
}
void _nutzerWechseln() {
final ctrl = TextEditingController(text: _nutzer);
final pwCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('👤 Anmelden', style: TextStyle(color: Colors.white, fontSize: 15)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: ctrl,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Nutzername...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: pwCtrl,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Passwort...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
final name = ctrl.text.trim();
final pass = pwCtrl.text;
if (name.isEmpty || pass.isEmpty) return;
final ok = await _vm.cloud.login(name, pass);
if (!ctx.mounted) return;
Navigator.pop(ctx);
if (ok) {
final p = await SharedPreferences.getInstance();
await p.setString('melo_nutzer', name);
if (mounted) setState(() => _nutzer = name);
_vm.setzeNutzer(name);
// Login-Effekt: Akzentfarbe + Sound + Begrüßung pro Person
UserEffekt.anwenden(name);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(UserEffekt.fuer(name).begruessung),
duration: const Duration(seconds: 3)));
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('❌ Login fehlgeschlagen Name oder Passwort falsch')));
}
}
},
child: const Text('OK', style: TextStyle(color: MeloTheme.rot)),
),
],
),
);
}
@override
void dispose() {
_vm.dispose();
super.dispose();
}
Future<void> _zeigeSuche() async {
final controller = TextEditingController();
String modus = 'Alle';
final ergebnis = await showDialog<String>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🔍 Song suchen', style: TextStyle(color: Colors.white, fontSize: 18)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Titel, Künstler oder Tag...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
_suchChip('Alle', modus == 'Alle', () => setDialogState(() => modus = 'Alle')),
const SizedBox(width: 6),
_suchChip('Titel', modus == 'Titel', () => setDialogState(() => modus = 'Titel')),
const SizedBox(width: 6),
_suchChip('Künstler', modus == 'Künstler', () => setDialogState(() => modus = 'Künstler')),
const SizedBox(width: 6),
_suchChip('Tag', modus == 'Tag', () => setDialogState(() => modus = 'Tag')),
],
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.pop(ctx, '${controller.text}|$modus'),
child: const Text('Suchen', style: TextStyle(color: MeloTheme.rot)),
),
],
),
),
);
if (ergebnis == null || ergebnis.isEmpty) return;
final teile = ergebnis.split('|');
final suchtext = teile[0].toLowerCase();
final suchModus = teile.length > 1 ? teile[1] : 'Alle';
if (suchtext.isEmpty) return;
// Tag-Namen die zum Suchtext passen
final matchingTags = _vm.tags
.where((t) => t['name'] != 'Alle' && t['name']!.toLowerCase().contains(suchtext))
.map((t) => t['name']!)
.toSet();
final gefiltert = _vm.songs.where((s) {
if (suchModus == 'Titel') return s.titel.toLowerCase().contains(suchtext);
if (suchModus == 'Künstler') return s.kuenstler.toLowerCase().contains(suchtext);
if (suchModus == 'Tag') {
return s.tagIds != null && matchingTags.any((name) {
final tag = _vm.tagsMap[name];
return tag != null && s.tagIds!.contains(tag.id);
});
}
// Alle
if (s.titel.toLowerCase().contains(suchtext)) return true;
if (s.kuenstler.toLowerCase().contains(suchtext)) return true;
return s.tagIds != null && matchingTags.any((name) {
final tag = _vm.tagsMap[name];
return tag != null && s.tagIds!.contains(tag.id);
});
}).toList();
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Text('🔍 ${gefiltert.length} Treffer', style: const TextStyle(color: Colors.white)),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: gefiltert.isEmpty
? const Center(child: Text('Keine Treffer', style: TextStyle(color: Colors.grey)))
: ListView.builder(
itemCount: gefiltert.length,
itemBuilder: (_, i) => ListTile(
leading: Container(
width: 36, height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: const LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
),
child: const Center(child: Text('♪', style: TextStyle(fontSize: 14, color: Colors.white54))),
),
title: Text(gefiltert[i].titel, style: const TextStyle(color: Colors.white)),
subtitle: Text(gefiltert[i].kuenstler, style: const TextStyle(color: Colors.grey)),
onTap: () { Navigator.pop(ctx); _vm.spieleSong(gefiltert[i]); },
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen'))],
),
);
}
Future<void> _scanMusik() async {
final erlaubt = await _vm.scanner.frageSpeicherZugriff();
if (!erlaubt) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bitte Speicherzugriff erlauben')),
);
}
return;
}
await _vm.scanner.scanneMusikOrdner();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scannen fertig: ${_vm.scanner.anzahlNeueSongs} neue Songs gefunden')),
);
await _vm.ladeSongs();
}
}
void _zeigeServerBrowser() {
if (_vm.navidrome.istVerbunden) {
// Abmelden
_vm.navidrome.setCredentials('', '', '');
_vm.navidromeAlben.clear();
setState(() {});
return;
}
// Verbinden: einfacher Login-Dialog
final urlCtrl = TextEditingController(text: AppConfig.navidromeUrl);
final userCtrl = TextEditingController();
final passCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🌐 Navidrome verbinden', style: TextStyle(color: Colors.white, fontSize: 15)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Server', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
const SizedBox(height: 8),
TextField(controller: userCtrl, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Benutzer', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
const SizedBox(height: 8),
TextField(controller: passCtrl, obscureText: true, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Passwort', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
_vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text);
await _vm.ladeNavidromeAlben();
await _vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text);
if (ctx.mounted) Navigator.pop(ctx);
setState(() {});
},
child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)),
),
],
),
);
}
void _zeigePlaylistSheet() {
showModalBottomSheet(
context: context,
backgroundColor: MeloTheme.schwarz,
isScrollControlled: true,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: PlaylistSheet(vm: _vm),
),
);
}
void _songLoeschen(Song song) async {
if (song.id != null) {
final db = DbHelper();
await db.loeschSong(song.id!);
if (song.dateiPfad.isNotEmpty) {
try { await File(song.dateiPfad).delete(); } catch (_) {}
}
_vm.ladeSongs();
}
}
void _zeigePlaylists() {
showModalBottomSheet(
context: context,
backgroundColor: MeloTheme.schwarz,
isScrollControlled: true,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: PlaylistSheet(vm: _vm),
),
);
}
void _zeigeAddToPlaylist(Song song) async {
final playlists = await _vm.playlists.allePlaylists();
if (!mounted || playlists.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erst eine Playlist erstellen')),
);
}
return;
}
final auswahl = await showDialog<Playlist>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('Zu Playlist hinzufügen', style: TextStyle(color: Colors.white, fontSize: 16)),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.queue_music, color: MeloTheme.rot, size: 18),
title: Text(playlists[i].name, style: const TextStyle(color: Colors.white, fontSize: 14)),
subtitle: Text('${playlists[i].songCount} Songs', style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
onTap: () => Navigator.pop(ctx, playlists[i]),
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen'))],
),
);
if (auswahl != null && song.id != null) {
await _vm.playlists.songHinzufuegen(auswahl.id!, song.id!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ ${auswahl.name}')),
);
}
}
}
Future<void> _zeigeDownloadDialog() async {
// Statt Dialog → zum Download-Tab wechseln
setState(() => _aktiverTab = 1);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _vm,
builder: (_, _) {
if (_vm.ladt) {
return const Scaffold(
backgroundColor: MeloTheme.schwarz,
body: Center(child: CircularProgressIndicator(color: MeloTheme.rot)),
);
}
final gesamtMB = _vm.songs.isEmpty ? '0.0'
: (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(1);
final gesamtMin = _vm.songs.isEmpty ? 0
: (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round();
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: SafeArea(
child: _aktiverTab == 1
? DownloadScreen(
downloader: _vm.downloader,
onSongsChanged: _vm.ladeSongs,
)
: _aktiverTab == 3
? CloudScreen(cloud: _cloud, onSongsChanged: _vm.ladeSongs)
: Column(
children: [
MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeProfil, onSettings: _zeigeEinstellungen),
if (_vm.zeigeBotschaft) _botschaftBanner(),
_tagBereich(),
FutureBuilder<int>(
future: _favoritenZahl,
builder: (context, snap) => StatistikCard(
anzahlSongs: _vm.songs.length,
gesamtMB: gesamtMB,
gesamtMin: gesamtMin,
anzahlFavoriten: snap.data ?? 0,
),
),
RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
Expanded(child: _songListe()),
const MiniPlayer(),
const SizedBox(height: 8),
],
),
),
bottomNavigationBar: _bottomNav(),
);
},
);
}
Widget _songListe() {
final songs = _vm.gefilterteSongs;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('📂 Alle Songs', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
Row(children: [
Text('${songs.length} Titel${_vm.aktiveTags.isNotEmpty ? ' (gefiltert)' : ''}', style: TextStyle(fontSize: 12, color: MeloTheme.rot)),
const SizedBox(width: 8),
GestureDetector(
onTap: _scanMusik,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: MeloTheme.dunkel2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 12, color: MeloTheme.rot),
SizedBox(width: 4),
Text('Scannen', style: TextStyle(fontSize: 11, color: MeloTheme.rot)),
],
),
),
),
]),
],
),
),
Expanded(
child: songs.isEmpty
? _emptyStateWidget()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
itemCount: songs.length,
itemBuilder: (_, i) => SongTile(
song: songs[i],
istFavorit: songs[i].id != null && _vm.favoritenIds.contains(songs[i].id),
onFavoriteToggle: _vm.favoritenUmschalten,
onPlay: _vm.spieleSong,
onMetadataChanged: _vm.ladeSongs,
onAddToPlaylist: _zeigeAddToPlaylist,
onDelete: _songLoeschen,
),
),
),
],
);
}
void _zeigeEinstellungen() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('⚙ Einstellungen', style: TextStyle(color: Colors.white, fontSize: 16)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Stats
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(color: MeloTheme.dunkel2, borderRadius: BorderRadius.circular(10)),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
_statWert('${_vm.songs.length}', 'Songs'),
_statWert('${(_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(1)} MB', 'Größe'),
_statWert('${(_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round()} Min', 'Dauer'),
_statWert('${_vm.favoritenIds.length}', '❤️'),
]),
),
ListTile(
leading: const Icon(Icons.favorite, color: MeloTheme.rot),
title: const Text('Favoriten', style: TextStyle(color: Colors.white)),
subtitle: const Text('Deine Lieblingssongs', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () {
Navigator.pop(ctx);
_vm.aktiveTags = {'★ Favoriten'};
},
),
ListTile(
leading: const Icon(Icons.cloud, color: MeloTheme.rot),
title: const Text('Cloud Sync', style: TextStyle(color: Colors.white)),
subtitle: const Text('Auto-Sync & Einstellungen', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () {
Navigator.pop(ctx);
showDialog(
context: context,
builder: (_) => const CloudEinstellungen(),
);
},
),
ListTile(
leading: const Icon(Icons.dns, color: MeloTheme.rot),
title: const Text('Server verbinden', style: TextStyle(color: Colors.white)),
subtitle: const Text('Navidrome / Musik-Server', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () { Navigator.pop(ctx); _zeigeServerBrowser(); },
),
ListTile(
leading: const Icon(Icons.person, color: MeloTheme.rot),
title: const Text('Profil', style: TextStyle(color: Colors.white)),
subtitle: Text(_nutzer.isEmpty ? 'Nicht angemeldet' : _nutzer, style: const TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () { Navigator.pop(ctx); _zeigeProfil(); },
),
SwitchListTile(
title: const Text('Diagnosedaten senden', style: TextStyle(color: Colors.white, fontSize: 13)),
subtitle: const Text('Absturz-Logs & Nutzungsdaten', style: TextStyle(color: Colors.grey, fontSize: 11)),
value: AppConfig.sendeDiagnosedaten,
activeColor: MeloTheme.rot,
onChanged: (v) {
AppConfig.sendeDiagnosedaten = v;
},
),
if (Platform.isAndroid)
ListTile(
leading: const Icon(Icons.folder_open, color: MeloTheme.rot),
title: const Text('Voller Speicherzugriff', style: TextStyle(color: Colors.white)),
subtitle: const Text('Zum Speichern in Downloads/Music', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () async {
final status = await Permission.manageExternalStorage.request();
if (status.isGranted) {
await SharedPreferences.getInstance().then((p) => p.setBool('manage_storage', true));
}
},
),
],
),
),
);
}
Widget _bottomNav() {
return Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.dunkel1)),
),
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: MeloTheme.schwarz,
selectedItemColor: MeloTheme.rot,
unselectedItemColor: MeloTheme.textSekundaer,
currentIndex: _aktiverTab.clamp(0, 3),
onTap: (i) {
setState(() => _aktiverTab = i);
if (i == 0 && _vm.aktiveTags.contains('★ Favoriten')) {
_vm.aktiveTags.clear();
} else if (i == 2) {
// Playlisten öffnen
_zeigePlaylists();
}
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'),
BottomNavigationBarItem(icon: Icon(Icons.add_circle, size: 22), label: 'Lied +'),
BottomNavigationBarItem(icon: Icon(Icons.queue_music, size: 22), label: 'Playlisten'),
BottomNavigationBarItem(icon: Icon(Icons.cloud, size: 22), label: 'Cloud'),
],
),
);
}
/// Hilfs-Widget für Such-Modus-Chips
Widget _suchChip(String label, bool aktiv, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(12),
),
child: Text(label, style: TextStyle(fontSize: 11, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
),
);
}
/// Banner "💌 Seit 2008" erscheint nach 10 Playbacks
Widget _botschaftBanner() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF2A0000), Color(0xFF1A0000)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Text('💌', style: TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Expanded(
child: Text(_vm.nutzerBotschaft ?? '🎵 Danke fürs Zuhören!',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
),
GestureDetector(
onTap: _vm.botschaftAusblenden,
child: const Icon(Icons.close, size: 16, color: MeloTheme.textSekundaer),
),
],
),
),
);
}
/// Leerer Zustand wenn keine Songs in der Bibliothek sind
Widget _emptyStateWidget() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.library_music_outlined, size: 56, color: MeloTheme.rot.withValues(alpha: 0.5)),
const SizedBox(height: 16),
const Text('Noch keine Songs in Melo',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('Füge Songs über "Lied +" hinzu, verbinde deinen Server oder starte einen lokalen Scan.',
textAlign: TextAlign.center,
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12, height: 1.4)),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _scanMusik,
icon: const Icon(Icons.search, size: 16),
label: const Text('Jetzt Musik scannen'),
style: ElevatedButton.styleFrom(
backgroundColor: MeloTheme.dunkel1,
foregroundColor: Colors.white,
side: const BorderSide(color: MeloTheme.dunkel2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
),
);
}
Widget _tagBereich() {
return AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Column(
children: [
// Toggle-Kopf: Tag-Leiste ein-/ausklappen (war vorher unerreichbar!)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Icon(Icons.label_outline,
size: 14, color: MeloTheme.textSekundaer),
const SizedBox(width: 6),
Text('Tags & Filter',
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 12)),
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(
_tagsOffen ? Icons.expand_less : Icons.expand_more,
size: 18,
color: MeloTheme.textSekundaer),
onPressed: () => setState(() => _tagsOffen = !_tagsOffen),
),
],
),
),
if (_tagsOffen) ...[
TagLeiste(
tags: _vm.tags,
aktiveTags: _vm.aktiveTags,
onTagToggled: _vm.toggleTag,
),
const SizedBox(height: 8),
TagStatsWidget(tagCounts: _vm.tagCounts),
],
],
),
);
}
Widget _statWert(String wert, String label) {
return Column(children: [
Text(wert, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700)),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 10)),
]);
}
bool _tagsOffen = false;
}