Security: Cloud-Auth auf Bearer-JWT umgestellt (IDOR-Luecke geschlossen)
- cloud_service: echter Login gegen baka-auth, Token statt X-API-Key/X-User - app_config: hartcodierten API-Key-Default entfernt - download_service + melo_logger: Bearer-Token statt X-API-Key - navidrome: Passwort in flutter_secure_storage (Keychain/Keystore) - song: token-haltige stream_url wird nicht mehr in SQLite persistiert - cloud_screen: Pfad-Traversal beim Download-Dateinamen gefixt (p.basename) - home_screen: Login-Dialog mit Passwort-Feld, Auto-Sync nutzt restoreLogin
This commit is contained in:
+373
-62
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
@@ -12,6 +14,12 @@ 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 '../services/cloud_service.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class MeloHome extends StatefulWidget {
|
||||
const MeloHome({super.key});
|
||||
@@ -22,14 +30,142 @@ class MeloHome extends StatefulWidget {
|
||||
|
||||
class _MeloHomeState extends State<MeloHome> {
|
||||
final MeloHomeViewModel _vm = MeloHomeViewModel();
|
||||
final CloudService _cloud = CloudService();
|
||||
int _aktiverTab = 0;
|
||||
|
||||
String _nutzer = 'Baka'; // Aktueller Nutzer
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
void _zeigeProfil() {
|
||||
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', '${_cloud.status().then((s) => s?['total'] ?? 0)}'),
|
||||
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 {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.remove('melo_nutzer');
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
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);
|
||||
} 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();
|
||||
@@ -89,22 +225,28 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
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 _vm.tags.any((t) =>
|
||||
t['name'] != 'Alle' &&
|
||||
t['name']!.toLowerCase().contains(suchtext) &&
|
||||
'${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase()));
|
||||
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 _vm.tags.any((t) =>
|
||||
t['name'] != 'Alle' &&
|
||||
t['name']!.toLowerCase().contains(suchtext) &&
|
||||
'${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase()));
|
||||
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(
|
||||
@@ -159,30 +301,79 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
}
|
||||
|
||||
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: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: MeloTheme.dunkel2,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(child: NavidromeBrowser(vm: _vm)),
|
||||
],
|
||||
),
|
||||
child: PlaylistSheet(vm: _vm),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _zeigePlaylistSheet() {
|
||||
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,
|
||||
@@ -252,8 +443,8 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
);
|
||||
}
|
||||
|
||||
final gesamtMB = _vm.songs.isEmpty ? '0'
|
||||
: (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0);
|
||||
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();
|
||||
|
||||
@@ -265,29 +456,13 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
downloader: _vm.downloader,
|
||||
onSongsChanged: _vm.ladeSongs,
|
||||
)
|
||||
: _aktiverTab == 3
|
||||
? CloudScreen(cloud: _cloud, onSongsChanged: _vm.ladeSongs)
|
||||
: Column(
|
||||
children: [
|
||||
MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeServerBrowser),
|
||||
// ─── EIN/AUS: RecentWidget (Zuletzt gehört) ───
|
||||
// Entferne die Kommentarzeichen um RecentWidget zu aktivieren:
|
||||
// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
|
||||
StatistikCard(
|
||||
anzahlSongs: _vm.songs.length,
|
||||
gesamtMB: gesamtMB,
|
||||
gesamtMin: gesamtMin,
|
||||
anzahlFavoriten: _vm.favoritenIds.length,
|
||||
),
|
||||
// ─── EIN/AUS: Hidden Message "Seit 2008" ───
|
||||
// Entferne die Kommentarzeichen um die Botschaft zu aktivieren:
|
||||
MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeProfil, onSettings: _zeigeEinstellungen),
|
||||
if (_vm.zeigeBotschaft) _botschaftBanner(),
|
||||
TagLeiste(
|
||||
tags: _vm.tags,
|
||||
aktiveTags: _vm.aktiveTags,
|
||||
onTagToggled: _vm.toggleTag,
|
||||
),
|
||||
// ─── EIN/AUS: TagStatsWidget (Tag-Counts) ───
|
||||
// Entferne die Kommentarzeichen um TagStatsWidget zu aktivieren:
|
||||
// TagStatsWidget(tagCounts: _vm.tagCounts),
|
||||
_tagBereich(),
|
||||
Expanded(child: _songListe()),
|
||||
const MiniPlayer(),
|
||||
const SizedBox(height: 8),
|
||||
@@ -336,7 +511,9 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
child: songs.isEmpty
|
||||
? _emptyStateWidget()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
|
||||
itemCount: songs.length,
|
||||
itemBuilder: (_, i) => SongTile(
|
||||
@@ -346,6 +523,7 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
onPlay: _vm.spieleSong,
|
||||
onMetadataChanged: _vm.ladeSongs,
|
||||
onAddToPlaylist: _zeigeAddToPlaylist,
|
||||
onDelete: _songLoeschen,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -353,6 +531,87 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -363,18 +622,21 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
backgroundColor: MeloTheme.schwarz,
|
||||
selectedItemColor: MeloTheme.rot,
|
||||
unselectedItemColor: MeloTheme.textSekundaer,
|
||||
currentIndex: _aktiverTab,
|
||||
currentIndex: _aktiverTab.clamp(0, 3),
|
||||
onTap: (i) {
|
||||
setState(() => _aktiverTab = i);
|
||||
if (i == 2) _zeigePlaylistSheet(); // Tags-Tab → Playlists
|
||||
if (i == 3) _zeigePlaylistSheet(); // Favoriten-Tab → Playlists
|
||||
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.download, size: 22), label: 'Downloads'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.label, size: 22), label: 'Tags'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.favorite, size: 22), label: 'Favoriten'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.settings, size: 22), label: 'Einstellungen'),
|
||||
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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -411,15 +673,8 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
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)),
|
||||
],
|
||||
),
|
||||
child: Text(_vm.nutzerBotschaft ?? '🎵 Danke fürs Zuhören!',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _vm.botschaftAusblenden,
|
||||
@@ -430,4 +685,60 @@ class _MeloHomeState extends State<MeloHome> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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: _tagsOffen
|
||||
? TagLeiste(
|
||||
tags: _vm.tags,
|
||||
aktiveTags: _vm.aktiveTags,
|
||||
onTagToggled: _vm.toggleTag,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user