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)andClaude Haiku 4.5 2b10c350f9 Navidrome-Integration vollständig: Browser-Widget in Home-Screen eingebaut
- NavidromeBrowser-Widget als BottomSheet verfügbar
- Dialog erweitert: "Browser öffnen" zeigt draggable Album/Song-Liste
- Musikserver-Statuspunkt bereits im initState geladen
- Alle 202 Tests grün (Verify-Fix durch isolierte navServer-Zustandsänderung)

Workflow:
1. Nutzer tippt auf Musikserver-Chip (oben im Header)
2. Dialog zeigt Verbindungsstatus + "Browser öffnen"-Button
3. BottomSheet öffnet Album-Liste (0.7..0.95 Höhe, draggable)
4. Download oder Stream direkt von Navidrome möglich

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8HvgtKSsaDZ8bjZrYBBms
2026-08-19 06:50:00 +02:00

1648 lines
59 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 'package:flutter/material.dart';
import 'dart:async';
import 'dart:io';
import '../viewmodels/melo_home_viewmodel.dart';
import '../models/song.dart';
import '../models/playlist.dart';
import '../utils/farb_theme.dart';
import '../widgets/mini_player.dart';
import '../widgets/melo_header.dart';
import '../widgets/statistik_card.dart';
import '../widgets/tag_leiste.dart';
import '../widgets/song_tile.dart';
import '../widgets/press_scale.dart';
import '../widgets/recent_widget.dart';
import 'download_screen.dart';
import 'cloud_screen.dart';
import 'settings_screen.dart';
import 'login_screen.dart';
import 'recap_screen.dart';
import 'now_playing_screen.dart';
import 'erweitert_screen.dart';
import '../services/cloud_service.dart';
import '../services/player_service.dart';
import '../services/auth_service.dart';
import '../services/sync_service.dart';
import '../services/realtime_sync_service.dart';
import '../config/app_config.dart';
import '../widgets/navidrome_browser.dart';
class MeloHome extends StatefulWidget {
const MeloHome({super.key});
@override
State<MeloHome> createState() => _MeloHomeState();
}
class _MeloHomeState extends State<MeloHome> with WidgetsBindingObserver {
final RealtimeSyncService _realtimeSync = RealtimeSyncService();
final MeloHomeViewModel _vm = MeloHomeViewModel();
final CloudService _cloud = CloudService();
/// 0=Start, 1=Bibliothek, 2=Jetzt läuft, 3=Cloud, 4=Mehr
int _aktiverTab = 0;
/// Aktives Segment im Bibliothek-Tab
String _bibSegment = 'Songs';
static const _bibSegmente = [
'Songs', 'Alben', 'Künstler', 'Jahre', 'Genres',
'Tags', 'Playlists', 'Favoriten', 'Downloads',
];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_vm.ladeSongs();
// Gespeicherte Navidrome-Zugangsdaten laden → Musikserver-Statuspunkt
_vm.navidrome.ladeGespeicherteZugangsdaten().then((_) {
if (mounted) setState(() {});
});
// Cloud-Verbindung beim App-Start herstellen (feuern-und-vergessen).
_cloud.verbinde();
// Realtime-Sync starten (SSE-Push, wenn Modus=Echtzeit)
_realtimeSync.starteWennAktiviert();
// Tipp auf die Sync-Abschluss-/Fehler-Notification → Cloud-Tab öffnen
SyncService.syncBenachrichtigungGetippt.addListener(_syncNotifGetippt);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
SyncService.syncBenachrichtigungGetippt.removeListener(_syncNotifGetippt);
_realtimeSync.stoppe();
_vm.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.resumed:
_realtimeSync.fortsetzen();
case AppLifecycleState.paused:
case AppLifecycleState.detached:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
_realtimeSync.pausiere();
}
}
/// Öffnet den Cloud-Tab, wenn die Sync-Notification getippt wurde.
void _syncNotifGetippt() {
if (!mounted) return;
setState(() => _aktiverTab = 3);
}
// ─── Navigation ─────────────────────────────────────
/// Öffnet den Now-Playing-Fullscreen mit Slide-up-Animation
void _oeffneNowPlaying() {
Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, __, ___) => const NowPlayingScreen(),
transitionsBuilder: (_, animation, __, child) {
final offset = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
);
return SlideTransition(position: offset, child: child);
},
),
);
}
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)),
),
],
),
),
);
controller.dispose();
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;
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);
});
}
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();
}
}
Future<void> _zeigeServerBrowser() async {
if (_vm.navidrome.istVerbunden) {
await _vm.navidrome.loescheZugangsdaten();
_vm.navidromeAlben.clear();
if (mounted) setState(() {});
return;
}
final urlCtrl = TextEditingController(text: AppConfig.navidromeUrl);
final userCtrl = TextEditingController();
final passCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🌐 Musikserver 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)),
),
],
),
).then((_) {
urlCtrl.dispose();
userCtrl.dispose();
passCtrl.dispose();
});
}
Future<void> _zeigeMusikserverDetail() async {
final verbunden = _vm.navidrome.istVerbunden;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Row(
children: [
Text('☁️', style: TextStyle(fontSize: 20)),
SizedBox(width: 8),
Text('Musikserver', style: TextStyle(color: Colors.white, fontSize: 17)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: verbunden ? const Color(0xFF4CAF50) : const Color(0xFFEF5350),
),
),
const SizedBox(width: 8),
Text(
verbunden ? 'Verbunden' : 'Offline',
style: TextStyle(
color: verbunden ? const Color(0xFFA5D6A7) : const Color(0xFFEF9A9A),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 10),
Text(
verbunden
? 'Verbunden mit Navidrome · musik.baka-net.de'
: 'Nicht mit dem Musikserver verbunden.\nTippe auf „Verbinden", um Alben zu durchstöbern.',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen')),
if (verbunden)
TextButton(
onPressed: () async {
Navigator.pop(ctx);
await _vm.navidrome.loescheZugangsdaten();
_vm.navidromeAlben.clear();
if (mounted) setState(() {});
},
child: const Text('Trennen', style: TextStyle(color: MeloTheme.textSekundaer)),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
if (verbunden) {
_zeigeNavidromeBrowser();
} else {
_zeigeServerBrowser();
}
},
child: Text(
verbunden ? 'Browser öffnen' : 'Verbinden',
style: const TextStyle(color: MeloTheme.rot),
),
),
],
),
);
}
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}')),
);
}
}
}
void _zeigeDownloadDialog() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DownloadScreen(
downloader: _vm.downloader,
onSongsChanged: _vm.ladeSongs,
),
),
);
}
Future<void> _erneutHerunterladen(Song song) async {
final neu = await _vm.downloader.reDownload(song);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(neu != null ? '🔄 Neu geladen: ${neu.titel}' : 'Fehler: ${_vm.downloader.fehler ?? 'unbekannt'}'),
),
);
await _vm.ladeSongs();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([_vm, _cloud, SyncService.laeuftNotifier]),
builder: (_, _) {
if (_vm.ladt) {
return const Scaffold(
backgroundColor: MeloTheme.schwarz,
body: Center(child: CircularProgressIndicator(color: MeloTheme.rot)),
);
}
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: SafeArea(
child: switch (_aktiverTab) {
1 => _bibliothekTab(),
2 => _jetztLaeuftTab(),
3 => CloudScreen(
cloud: _cloud,
onZurueck: () => setState(() => _aktiverTab = 0),
),
4 => _mehrTab(),
_ => _startTab(),
},
),
bottomNavigationBar: _bottomNav(),
);
},
);
}
// ─── Tab: 🏠 Start ──────────────────────────────────
Widget _startTab() {
return Column(
children: [
MeloHeader(
onDownload: _zeigeDownloadDialog,
onSearch: _zeigeSuche,
onServer: _zeigeMusikserverDetail,
serverVerbunden: _vm.navidrome.istVerbunden,
serverSyncLaeuft: SyncService.laeuftNotifier.value ||
_cloud.status == CloudStatus.verbinde ||
_vm.serverLadt,
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 8),
children: [
// ═══ Hero „Weiterhören"-Karte ═══
_weiterhoerenHeroKarte(),
// Weiterhören / Zuletzt gehört
RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
// Jahres-Recap (wie Spotify Wrapped)
_recapKarte(),
// Hidden Message "Seit 2008"
if (_vm.zeigeBotschaft) _botschaftBanner(),
StatistikCard(
anzahlSongs: _vm.songs.length,
gesamtMB: _vm.songs.isEmpty
? '0'
: (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0),
gesamtMin: _vm.songs.isEmpty
? 0
: (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round(),
anzahlFavoriten: _vm.favoritenIds.length,
),
_favoritenSchnellzugriff(),
_neueDownloads(),
],
),
),
const MiniPlayer(),
const SizedBox(height: 8),
],
);
}
/// Hero „Weiterhören" zeigt den aktuellen/last Song als große Card mit Glow
Widget _weiterhoerenHeroKarte() {
final aktuellerSong = PlayerService().aktuellerSong;
// Fallback: letzten gespielten Song verwenden
final song = aktuellerSong ?? (_vm.letzteSongs.isNotEmpty ? _vm.letzteSongs.first : null);
if (song == null) return const SizedBox.shrink();
final hatCover = song.coverPfad != null && File(song.coverPfad!).existsSync();
final akzent = MeloTheme.akzent;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
child: GestureDetector(
onTap: () => _oeffneNowPlaying(),
child: AnimatedScale(
scale: 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
akzent.withValues(alpha: 0.3),
MeloTheme.schwarz,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color: akzent.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: akzent.withValues(alpha: 0.25),
blurRadius: 30,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Cover
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
akzent,
Color.lerp(akzent, Colors.black, 0.5)!,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: hatCover
? Image.file(File(song.coverPfad!), fit: BoxFit.cover)
: const Center(
child: Text('♪', style: TextStyle(fontSize: 28, color: Colors.white)),
),
),
),
const SizedBox(width: 16),
// Infos
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Weiterhören',
key: Key('weiterhoeren_label'),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: MeloTheme.textSekundaer,
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
Text(
song.titel,
key: const Key('weiterhoeren_titel'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: -0.5,
),
),
const SizedBox(height: 2),
Text(
song.kuenstler,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: MeloTheme.textSekundaer,
),
),
],
),
),
// Play-Button mit Glow
_playButton(akzent),
],
),
),
),
),
),
);
}
/// Press-Scale Play-Button mit Glow (Mikro-Interaktion)
Widget _playButton(Color akzent) {
return PressScale(
onTap: () => _oeffneNowPlaying(),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [akzent, Color.lerp(akzent, Colors.black, 0.4)!],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: akzent.withValues(alpha: 0.35),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 28),
),
);
}
/// ⭐ Favoriten-Schnellzugriff auf dem Start-Tab (horizontale Chips)
Widget _favoritenSchnellzugriff() {
final favoriten = _vm.songs
.where((s) => s.id != null && _vm.favoritenIds.contains(s.id))
.take(8)
.toList();
if (favoriten.isEmpty) return const SizedBox.shrink();
return _horizontaleListe(
titel: '⭐ Deine Favoriten',
songs: favoriten,
leerText: '',
);
}
/// ⬇ Neue Downloads auf dem Start-Tab (die 5 zuletzt hinzugefügten)
Widget _neueDownloads() {
final neue = _vm.songs.where((s) => s.istHeruntergeladen).take(5).toList();
if (neue.isEmpty) return const SizedBox.shrink();
return _horizontaleListe(
titel: '⬇ Neue Downloads',
songs: neue,
leerText: '',
);
}
Widget _horizontaleListe({
required String titel,
required List<Song> songs,
required String leerText,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white70),
),
const SizedBox(height: 8),
SizedBox(
height: 52,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: songs.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => GestureDetector(
onTap: () => _vm.spieleSong(songs[i]),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
colors: [
MeloTheme.akzent.withValues(alpha: 0.4),
MeloTheme.akzent.withValues(alpha: 0.1),
],
),
),
child: const Center(child: Text('♪', style: TextStyle(fontSize: 12))),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(songs[i].titel,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: Colors.white)),
Text(songs[i].kuenstler,
style: const TextStyle(fontSize: 9, color: MeloTheme.textSekundaer)),
],
),
],
),
),
),
),
),
],
),
);
}
// ─── Tab: 📚 Bibliothek ─────────────────────────────
Widget _bibliothekTab() {
return Column(
children: [
_bibliothekHeader(),
_segmentLeiste(),
Expanded(child: _bibliothekInhalt()),
const MiniPlayer(),
const SizedBox(height: 8),
],
);
}
Widget _bibliothekHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'📚 Bibliothek',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white),
),
Text(
'Alles an einem Ort',
style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer),
),
],
),
Row(children: [
_bibButton(Icons.add_circle_outline, 'Lied +', _zeigeDownloadDialog),
const SizedBox(width: 8),
_bibButton(Icons.search, 'Suche', _zeigeSuche),
]),
],
),
);
}
Widget _bibButton(IconData icon, String label, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: MeloTheme.akzent),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.w500)),
],
),
),
);
}
Widget _segmentLeiste() {
return SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
children: _bibSegmente.map((seg) {
final aktiv = _bibSegment == seg;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => setState(() => _bibSegment = seg),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.akzent : MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(999),
),
child: Text(
seg,
style: TextStyle(
fontSize: 12,
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
fontWeight: FontWeight.w600,
),
),
),
),
);
}).toList(),
),
);
}
Widget _bibliothekInhalt() {
switch (_bibSegment) {
case 'Alben':
return _gruppenListe(_gruppiereNach((s) => s.album));
case 'Künstler':
return _gruppenListe(_gruppiereNach((s) => s.kuenstler));
case 'Jahre':
return _gruppenListe(_gruppiereNach((s) => s.jahr ?? ''));
case 'Genres':
return _gruppenListe(_gruppiereNach((s) => s.genre ?? ''));
case 'Tags':
return _tagsSegment();
case 'Playlists':
return _playlistsSegment();
case 'Favoriten':
final favoriten = _vm.songs
.where((s) => s.id != null && _vm.favoritenIds.contains(s.id))
.toList();
return _songListe(quelle: favoriten, titel: '⭐ Favoriten');
case 'Downloads':
return _downloadsSegment();
default:
return Column(
children: [
TagLeiste(
tags: _vm.tags,
aktiveTags: _vm.aktiveTags,
onTagToggled: _vm.toggleTag,
),
Expanded(
child: _songListe(titel: '📂 Alle Songs'),
),
],
);
}
}
Map<String, List<Song>> _gruppiereNach(String Function(Song) schluessel) {
final gruppen = <String, List<Song>>{};
for (final s in _vm.songs) {
final key = schluessel(s).trim();
if (key.isEmpty) continue;
gruppen.putIfAbsent(key, () => []).add(s);
}
return gruppen;
}
Widget _gruppenListe(Map<String, List<Song>> gruppen) {
final keys = gruppen.keys.toList()..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
if (keys.isEmpty) {
return const Center(
child: Text('Keine Einträge', style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13)),
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
children: keys.map((key) {
final songs = gruppen[key]!;
return Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(Icons.folder_outlined, color: MeloTheme.akzent, size: 18),
title: Text(
key,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: Text(
'${songs.length} Song${songs.length != 1 ? 's' : ''}',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11),
),
childrenPadding: const EdgeInsets.only(bottom: 6),
children: songs.map((s) => SongTile(
song: s,
istFavorit: s.id != null && _vm.favoritenIds.contains(s.id),
onFavoriteToggle: _vm.favoritenUmschalten,
onPlay: _vm.spieleSong,
onMetadataChanged: _vm.ladeSongs,
onAddToPlaylist: _zeigeAddToPlaylist,
onErneutHerunterladen: _erneutHerunterladen,
onSpieleAlsNaechstes: (song) {
_vm.spieleAlsNaechstes(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('▶ Als Nächstes: ${song.titel}')),
);
},
onAmEndeHinzufuegen: (song) {
_vm.amEndeHinzufuegen(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ Am Ende hinzugefügt: ${song.titel}')),
);
},
)).toList(),
),
),
);
}).toList(),
);
}
Widget _tagsSegment() {
final eintraege = _vm.tagCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (eintraege.isEmpty) {
return const Center(
child: Text('Noch keine Tags', style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13)),
);
}
return ListView(
padding: const EdgeInsets.all(12),
children: [
...eintraege.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {
_vm.aktiveTags = {e.key};
setState(() => _bibSegment = 'Songs');
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
Icon(Icons.label_outline, color: MeloTheme.akzent, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
e.key,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
Text(
'${e.value} Song${e.value != 1 ? 's' : ''}',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
const SizedBox(width: 6),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 18),
],
),
),
),
),
)),
],
);
}
Widget _playlistsSegment() {
return FutureBuilder<List<Playlist>>(
future: _vm.playlists.allePlaylists(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator(color: MeloTheme.rot));
}
final playlists = snapshot.data ?? [];
if (playlists.isEmpty) {
return const Center(
child: Text(
'Noch keine Playlists\nTippe in der Songliste auf + um eine zu erstellen',
textAlign: TextAlign.center,
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
);
}
return ListView(
padding: const EdgeInsets.all(12),
children: playlists.map((pl) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _zeigePlaylistSongs(pl),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
Icon(Icons.queue_music, color: MeloTheme.akzent, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
pl.name,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
Text(
'${pl.songCount} Songs',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
const SizedBox(width: 6),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 18),
],
),
),
),
),
)).toList(),
);
},
);
}
Future<void> _zeigePlaylistSongs(Playlist pl) async {
final songs = await _vm.playlists.songs(pl.id!);
if (!mounted) return;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Text('📋 ${pl.name}', style: const TextStyle(color: Colors.white, fontSize: 17)),
content: SizedBox(
width: double.maxFinite,
height: 360,
child: songs.isEmpty
? const Center(child: Text('Leere Playlist', style: TextStyle(color: MeloTheme.textSekundaer)))
: ListView.builder(
itemCount: songs.length,
itemBuilder: (_, i) => ListTile(
leading: Icon(Icons.music_note, color: MeloTheme.akzent, size: 18),
title: Text(songs[i].titel, style: const TextStyle(color: Colors.white, fontSize: 14)),
subtitle: Text(songs[i].kuenstler, style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
onTap: () {
Navigator.pop(ctx);
_vm.spieleSong(songs[i], warteschlange: songs);
},
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen'))],
),
);
}
Widget _downloadsSegment() {
final downloads = _vm.songs.where((s) => s.istHeruntergeladen).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: GestureDetector(
onTap: _zeigeDownloadDialog,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
MeloTheme.akzent.withValues(alpha: 0.25),
MeloTheme.akzent.withValues(alpha: 0.05),
],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.akzent.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.add_circle_outline, color: MeloTheme.akzent, size: 22),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('⬇ Lied +',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white)),
Text('Neuen Song von YouTube herunterladen',
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
],
),
),
Icon(Icons.chevron_right, color: MeloTheme.akzent, size: 20),
],
),
),
),
),
Expanded(
child: _songListe(quelle: downloads, titel: '📂 Downloads'),
),
],
);
}
// ─── Tab: ▶️ Jetzt läuft ────────────────────────────
Widget _jetztLaeuftTab() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.play_circle_outline, size: 64, color: MeloTheme.dunkel2.withValues(alpha: 0.5)),
const SizedBox(height: 16),
const Text(
'Nichts spielt gerade',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
const SizedBox(height: 6),
const Text(
'Wähle einen Song in deiner Bibliothek,\ndann findest du ihn hier.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: MeloTheme.textSekundaer),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => setState(() => _aktiverTab = 1),
child: const Text('Zur Bibliothek'),
),
],
),
);
}
// ─── Tab: ⚙️ Mehr ───────────────────────────────────
Widget _mehrTab() {
return Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'⚙️ Mehr',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white),
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
_mehrKachel(
icon: Icons.settings_outlined,
titel: 'Einstellungen',
untertitel: 'Cloud Sync, Musikserver, Recap & mehr',
onTap: _oeffneEinstellungen,
),
_mehrKachel(
icon: Icons.handyman_outlined,
titel: 'Erweitert',
untertitel: 'Logs, Diagnose, Entwickler & Scanner',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ErweitertScreen(onScan: _scanMusik),
),
);
},
),
const SizedBox(height: 8),
_sektionHeader('Info'),
Container(
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
children: [
_infoZeile('Version', '2.56.0'),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Theme', 'Melo Dark Fusion'),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Musikserver', AppConfig.navidromeUrl),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Cloud', AppConfig.cloudUrl),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () async {
await AuthService().logout();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
},
icon: const Icon(Icons.logout, size: 18),
label: const Text('Abmelden', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: MeloTheme.akzent,
side: BorderSide(color: MeloTheme.akzent),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
),
),
const SizedBox(height: 24),
],
),
),
const MiniPlayer(),
const SizedBox(height: 8),
],
);
}
Widget _mehrKachel({
required IconData icon,
required String titel,
required String untertitel,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: MeloTheme.akzent, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
untertitel,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
],
),
),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 20),
],
),
),
),
),
);
}
Widget _sektionHeader(String titel) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(
titel,
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
),
),
);
}
Widget _infoZeile(String label, String wert) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
label,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
const Spacer(),
Flexible(
child: Text(
wert,
textAlign: TextAlign.right,
style: const TextStyle(color: Colors.white70, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
// ─── Einstellungen ──────────────────────────────────
Future<void> _oeffneEinstellungen() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
if (!mounted || result == null) return;
switch (result) {
case 'server':
_zeigeServerBrowser();
break;
case 'scanner':
_scanMusik();
break;
case 'logout':
await AuthService().logout();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
break;
}
}
// ─── NavigationBar ──────────────────────────────────
Widget _bottomNav() {
return Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.oberflaeche, width: 0)),
),
child: NavigationBar(
backgroundColor: MeloTheme.schwarz,
indicatorColor: MeloTheme.akzent.withValues(alpha: 0.25),
selectedIndex: _aktiverTab,
onDestinationSelected: (i) {
if (i == 2) {
if (PlayerService().aktuellerSong != null) {
_oeffneNowPlaying();
return;
}
setState(() => _aktiverTab = 2);
return;
}
setState(() => _aktiverTab = i);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined, size: 22), selectedIcon: Icon(Icons.home, size: 22), label: 'Start'),
NavigationDestination(icon: Icon(Icons.library_music_outlined, size: 22), selectedIcon: Icon(Icons.library_music, size: 22), label: 'Bibliothek'),
NavigationDestination(icon: Icon(Icons.play_circle_outline, size: 22), selectedIcon: Icon(Icons.play_circle_fill, size: 22), label: 'Jetzt läuft'),
NavigationDestination(icon: Icon(Icons.cloud_outlined, size: 22), selectedIcon: Icon(Icons.cloud, size: 22), label: 'Cloud'),
NavigationDestination(icon: Icon(Icons.more_horiz, size: 22), label: 'Mehr'),
],
),
);
}
// ─── Songliste ──────────────────────────────────────
Widget _songListe({List<Song>? quelle, String titel = '📂 Alle Songs'}) {
final songs = quelle ?? _vm.gefilterteSongs;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titel, style: const 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.akzent)),
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: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 12, color: MeloTheme.akzent),
const SizedBox(width: 4),
Text('Scannen', style: TextStyle(fontSize: 11, color: MeloTheme.akzent)),
],
),
),
),
]),
],
),
),
Expanded(
child: 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,
onErneutHerunterladen: _erneutHerunterladen,
onSpieleAlsNaechstes: (song) {
_vm.spieleAlsNaechstes(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('▶ Als Nächstes: ${song.titel}')),
);
},
onAmEndeHinzufuegen: (song) {
_vm.amEndeHinzufuegen(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ Am Ende hinzugefügt: ${song.titel}')),
);
},
),
),
),
],
);
}
Widget _suchChip(String label, bool aktiv, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.akzent : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(999),
),
child: Text(label, style: TextStyle(fontSize: 11, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
),
);
}
Widget _recapKarte() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const RecapScreen()),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
MeloTheme.akzent.withValues(alpha: 0.2),
MeloTheme.akzent.withValues(alpha: 0.05),
MeloTheme.schwarz,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.akzent.withValues(alpha: 0.35)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: MeloTheme.akzent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(14),
),
child: const Center(
child: Text('🎧', style: TextStyle(fontSize: 20)),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recap ${DateTime.now().year}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 2),
const Text(
'Woche · Monat · Jahr deine Top-Songs, Künstler & Hörzeit',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer),
),
],
),
),
Icon(Icons.chevron_right, color: MeloTheme.akzent, size: 22),
],
),
),
),
);
}
Widget _botschaftBanner() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
MeloTheme.akzent.withValues(alpha: 0.2),
MeloTheme.akzent.withValues(alpha: 0.05),
],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: MeloTheme.akzent.withValues(alpha: 0.35)),
),
child: Row(
children: [
const Text('💌', style: TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Seit 2008',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
Text('Danke, dass du immer da bist ♥',
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
],
),
),
GestureDetector(
onTap: _vm.botschaftAusblenden,
child: const Icon(Icons.close, size: 16, color: MeloTheme.textSekundaer),
),
],
),
),
);
}
/// BottomSheet für den Navidrome-Browser
void _zeigeNavidromeBrowser() {
showModalBottomSheet<void>(
context: context,
backgroundColor: MeloTheme.dunkel1,
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
builder: (_, controller) => NavidromeBrowser(vm: _vm),
),
);
}
}