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/cloud_screen.dart
T

2013 lines
68 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:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../utils/farb_theme.dart';
import '../utils/sanitize.dart';
import '../services/cloud_service.dart';
import '../services/favoriten_service.dart';
import '../services/favoriten_sync.dart';
import '../database/db_helper.dart';
import '../services/melo_logger.dart';
import '../models/song.dart';
import '../main.dart'; // notificationsPlugin
/// Melo Cloud Sync Screen v3 — vollständiges Sync-System
/// Playlisten, Favoriten, Auto-Sync, Persistent Login, Benutzerdefinierte Namen
class CloudScreen extends StatefulWidget {
final CloudService cloud;
final VoidCallback? onZurueck;
const CloudScreen({super.key, required this.cloud, this.onZurueck});
@override
State<CloudScreen> createState() => _CloudScreenState();
}
class _CloudScreenState extends State<CloudScreen>
with SingleTickerProviderStateMixin {
// ─── Status ───
int _serverCount = 0;
int _favServerCount = 0;
int _playlistServerCount = 0;
Map? _serverStatusDaten;
bool _ladt = false;
String? _status;
bool _statusOk = false;
bool _serverDatenGeladen = false;
// ─── Favoriten (bidirektionaler Sync) ───
final FavoritenService _favoriten = FavoritenService();
// ─── Sync-Animation ───
late final AnimationController _syncAnimController;
// ─── Sync-Einstellungen ───
bool _autoSync = true;
int _syncIntervall = 6;
Timer? _syncTimer;
String _letzterSync = 'Nie';
String? _naechsterSync;
// ─── Sync-Fortschritt ───
String _syncPhase = '';
double _syncFortschritt = 0;
/// UI-Flag: Fortschritts-Ansicht anzeigen („Im Hintergrund fortsetzen“
/// setzt NUR dieses Flag zurück).
bool _syncLaeuft = false;
/// Echter Sync-Guard: bleibt true, solange der Sync-Loop tatsächlich läuft
/// (auch im Hintergrund nach „Im Hintergrund fortsetzen“). Erst nach dem
/// echten Loop-Ende in `finally` wird er freigegeben → kein Doppel-Sync.
bool _syncProzessLaeuft = false;
int _syncedItems = 0;
// ─── Konflikt-Batch (MED-3) ───
/// Batch-Entscheidung für alle weiteren Konflikte dieses Sync-Laufs:
/// 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (jeden fragen)
String? _konfliktBatch;
// ─── Sync-Historie (heute) ───
List<Map<String, dynamic>> _syncHistorie = [];
// ─── Korrupt ───
List<Map> _korrupteSongs = [];
bool _ladtKorrupt = false;
bool _hatGeprueft = false;
// ─── Server-Playlisten ───
List<Map> _serverPlaylists = [];
bool _ladtPlaylists = false;
// ─── Server-Favoriten ───
List<Map> _serverFavorites = [];
bool _ladtFavorites = false;
// ─── Rename ───
final _renameTitleCtrl = TextEditingController();
final _renameArtistCtrl = TextEditingController();
@override
void initState() {
super.initState();
_syncAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
// Verbindung stellt der CloudService beim App-Start her (MeloHome.initState).
// Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist.
_ladeSettings();
_ladeSyncHistorie();
if (widget.cloud.istVerbunden) {
_serverDatenGeladen = true;
_ladeServerDaten();
_ladeStatus();
} else if (!widget.cloud.verbindungGestartet) {
// Fallback: Service wurde noch nicht gestartet (z.B. eigener Service
// aus den Einstellungen) — Verbindung hier anstoßen.
widget.cloud.verbinde();
}
widget.cloud.addListener(_onCloudStatus);
}
@override
void dispose() {
widget.cloud.removeListener(_onCloudStatus);
_syncTimer?.cancel();
_syncAnimController.dispose();
_renameTitleCtrl.dispose();
_renameArtistCtrl.dispose();
super.dispose();
}
// ─── Verbindung ───
/// Reagiert auf Status-Änderungen des CloudService: sobald verbunden,
/// werden Serverdaten + Zähler nachgeladen.
void _onCloudStatus() {
if (!mounted) return;
if (widget.cloud.istVerbunden && !_serverDatenGeladen) {
_serverDatenGeladen = true;
_ladeServerDaten();
_ladeStatus();
}
}
Future<void> _ladeSettings() async {
final p = await SharedPreferences.getInstance();
final letzter = p.getString('cloud_last_sync');
final letzterTs = p.getString('cloud_last_sync_ts');
if (mounted) {
setState(() {
_autoSync = p.getBool('cloud_auto') ?? true;
_syncIntervall = p.getInt('cloud_interval') ?? 6;
_letzterSync = letzter ??
(letzterTs != null
? _formatZeit(DateTime.tryParse(letzterTs))
: 'Nie');
});
}
_berechneNaechstenSync();
_starteAutoSync();
}
Future<void> _ladeServerDaten() async {
if (!widget.cloud.istVerbunden) return;
// Paralleles Laden
await Future.wait([
_ladePlaylists(),
_ladeFavorites(),
]);
}
void _berechneNaechstenSync() {
if (!_autoSync || _syncIntervall == 0) {
_naechsterSync = null;
return;
}
final now = DateTime.now();
final last = _letzterSync != 'Nie'
? DateTime.tryParse(_letzterSync)
: now;
if (last != null) {
final next = last.add(Duration(hours: _syncIntervall));
if (next.isBefore(now)) {
_naechsterSync = 'Jetzt fällig';
} else {
_naechsterSync = _formatZeit(next);
}
}
}
String _formatZeit(DateTime? dt) {
if (dt == null) return 'Nie';
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
void _starteAutoSync() {
_syncTimer?.cancel();
if (!_autoSync || _syncIntervall == 0) return;
_syncTimer = Timer.periodic(
Duration(hours: _syncIntervall),
(_) => _autoSyncDurchfuehren(),
);
}
Future<void> _autoSyncDurchfuehren() async {
if (_syncProzessLaeuft) return;
final erfolgreich = await _syncAlles(automatisch: true);
if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben
final p = await SharedPreferences.getInstance();
final now = DateTime.now();
final zeit = _formatZeit(now);
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now.toIso8601String());
if (mounted) {
setState(() {
_letzterSync = zeit;
});
_berechneNaechstenSync();
}
}
// ─── 🔄 Komplett-Sync ───
/// Komplett-Sync. [automatisch]=true (Auto-Sync-Timer): Konflikte werden
/// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still.
/// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein
/// Sync läuft (_syncProzessLaeuft-Guard).
Future<bool> _syncAlles({bool automatisch = false}) async {
if (_syncProzessLaeuft) return false;
_syncProzessLaeuft = true;
_konfliktBatch = null;
if (mounted) {
setState(() {
_syncLaeuft = true;
_syncPhase = 'Verbinde...';
_syncFortschritt = 0;
_syncedItems = 0;
});
}
_syncAnimController.repeat();
try {
// Phase 1: Songs synchronisieren
_updateSync('Vergleiche Songs...', 0.1);
final serverSongs = await widget.cloud.listSongs();
final db = DbHelper();
final dir =
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
int downloaded = 0;
int totalNew = 0;
// Zähle neue Songs
for (final song in serverSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
final existing = await db.songNachCloudId(sid);
if (existing == null) totalNew++;
}
// Downloade neue Songs + löse Konflikte (Titel lokal ≠ Server)
int processed = 0;
for (final song in serverSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
final title = (song['title'] ?? 'unknown').toString();
final existing = await db.songNachCloudId(sid);
if (existing != null) {
// Konfliktprüfung: Server-Titel ODER Server-Künstler weichen ab
final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim();
final serverKuenstler = (song['artist']?.toString() ?? '').trim();
final lokalerKuenstler = existing.kuenstler.trim();
final titelWeichtAb = serverTitel.isNotEmpty &&
lokalerTitel.toLowerCase() != serverTitel.toLowerCase();
final kuenstlerWeichtAb = serverKuenstler.isNotEmpty &&
lokalerKuenstler.toLowerCase() != serverKuenstler.toLowerCase();
if (titelWeichtAb || kuenstlerWeichtAb) {
// Batch: einmal gewählt → für alle weiteren Konflikte anwenden
String? wahl;
final batch = _konfliktBatch;
if (batch != null) {
wahl = batch == 'ueberspringen' ? 'lokal' : batch;
} else if (automatisch) {
// Auto-Sync: keine Dialoge — Server-Metadaten gewinnen still
wahl = 'server';
} else {
wahl = await _konfliktDialog(existing, song);
}
if (wahl == 'server') {
await db.cloudMetadatenAktualisieren(
existing.id!,
title: serverTitel,
artist: serverKuenstler,
);
_syncedItems++;
downloaded++;
} else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel);
var dest = '${dir.path}/$safeTitle';
if (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle (Server)';
}
if (await widget.cloud.download(sid, dest)) {
await db.songEinfuegen(Song(
titel: serverTitel,
kuenstler: (song['artist']?.toString() ?? '').trim(),
dauerSekunden: 0,
dateiPfad: dest,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
));
downloaded++;
_syncedItems++;
}
}
// 'lokal' → nichts tun (lokale Version behalten)
}
processed++;
continue;
}
_updateSync('Download: $title...',
0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1)));
final safeTitle = sanitizeDateiname(title);
final dest = '${dir.path}/$safeTitle';
if (await widget.cloud.download(sid, dest)) {
downloaded++;
// In DB eintragen mit cloud_id
// (vereinfacht: ID3-Reader würde Titel extrahieren)
}
processed++;
_syncedItems++;
await Future.delayed(
const Duration(milliseconds: 50)); // UI-Update erlauben
}
// Phase 2: Favoriten bidirektional synchronisieren
// Root-Cause-Fix (Sprint D): Vorher wurden die Server-Favoriten nur
// zurückgespiegelt (Server → Server) — lokale ⭐-Toggles gingen
// verloren. Jetzt: Vereinigung lokal Server, Push + lokales Markieren.
_updateSync('Sync Favoriten…', 0.55);
final serverFavs = await widget.cloud.getFavorites();
final serverIds = serverFavs
.map((f) => f['id']?.toString() ?? '')
.where((id) => id.isNotEmpty)
.toSet();
final lokalIds = await _favoriten.favoritenCloudIds();
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
// bleiben erhalten (kein Datenverlust in beide Richtungen)
await widget.cloud.syncFavorites(merged);
// Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song
// lokal existiert); `server_favorites` spiegelt den Merge-Zustand
for (final cid in serverIds) {
await _favoriten.merkeCloudFavorit(cid);
}
await db.serverFavoritesSet(merged);
setState(() => _favServerCount = merged.length);
_syncedItems += merged.length;
// Phase 3: Playlisten abgleichen
_updateSync('Lade Playlisten...', 0.7);
await _ladePlaylists();
// Phase 4: Sync-Metadaten aktualisieren
_updateSync('Speichere Sync-Zeitpunkt...', 0.9);
await widget.cloud.syncAll();
final now = DateTime.now().toIso8601String();
await db.syncMetaSet('last_full_sync', now);
_updateSync('Fertig!', 1.0);
if (mounted) {
setState(() {
_syncedItems = downloaded + merged.length;
});
_setzeStatus(
'$downloaded Songs + ${merged.length} Favoriten synchronisiert',
ok: true);
// Sync-Historie fürs Dashboard festhalten
await _syncHistorieEintragen(
dateien: downloaded,
favoriten: merged.length,
playlists: _serverPlaylists.length,
);
await _ladeStatus();
}
MeloLogger().aktion('cloud_sync_all', {
'downloaded': downloaded,
'favorites': merged.length,
'playlists': _serverPlaylists.length,
});
return true;
} catch (e) {
MeloLogger().fehler('cloud_sync_all', e);
_setzeStatus('Sync-Fehler: $e', ok: false);
return false;
} finally {
// Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während
// des Syncs verlassen wurde („Im Hintergrund fortsetzen“).
_syncProzessLaeuft = false;
try {
_syncAnimController.stop();
_syncAnimController.value = 0;
} catch (_) {
// Controller kann bereits disposed sein (Screen verlassen)
}
if (mounted) {
setState(() {
_syncLaeuft = false;
_syncPhase = '';
});
}
}
}
// ─── 📜 Sync-Historie (fürs Dashboard) ───
static const _historieKey = 'cloud_sync_history';
static const _historieCap = 30;
Future<void> _ladeSyncHistorie() async {
final p = await SharedPreferences.getInstance();
final roh = p.getStringList(_historieKey) ?? [];
final eintraege = <Map<String, dynamic>>[];
for (final s in roh) {
try {
final m = jsonDecode(s) as Map<String, dynamic>;
eintraege.add(m);
} catch (_) {
// kaputte Einträge ignorieren
}
}
if (mounted) setState(() => _syncHistorie = eintraege);
}
Future<void> _syncHistorieEintragen({
required int dateien,
required int favoriten,
required int playlists,
}) async {
final eintrag = <String, dynamic>{
'ts': DateTime.now().toIso8601String(),
'dateien': dateien,
'favoriten': favoriten,
'playlists': playlists,
};
final p = await SharedPreferences.getInstance();
final liste = p.getStringList(_historieKey) ?? [];
liste.add(jsonEncode(eintrag));
while (liste.length > _historieCap) {
liste.removeAt(0);
}
await p.setStringList(_historieKey, liste);
if (mounted) {
setState(() => _syncHistorie = [
..._syncHistorie,
eintrag,
]);
}
}
/// Einträge von heute zusammenfassen: „heute 14 Dateien, 2 Favoriten, 1 Playlist“
String get _syncHistorieHeuteText {
final heute = DateTime.now();
int dateien = 0;
int favoriten = 0;
int playlists = 0;
for (final e in _syncHistorie) {
final ts = DateTime.tryParse(e['ts']?.toString() ?? '');
if (ts == null) continue;
if (ts.year == heute.year &&
ts.month == heute.month &&
ts.day == heute.day) {
dateien += (e['dateien'] as num?)?.toInt() ?? 0;
favoriten += (e['favoriten'] as num?)?.toInt() ?? 0;
playlists += (e['playlists'] as num?)?.toInt() ?? 0;
}
}
if (dateien == 0 && favoriten == 0 && playlists == 0) {
return 'Noch keine Syncs heute';
}
final teile = <String>[
if (dateien > 0) '$dateien Dateien',
if (favoriten > 0) '$favoriten Favoriten',
if (playlists > 0) '$playlists Playlist${playlists != 1 ? 'en' : ''}',
];
return 'Heute: ${teile.join(', ')}';
}
/// Konflikt-Dialog: Titel ODER Künstler weichen lokal ≠ Server ab.
/// Dismissable (Tap außerhalb = Abbruch → lokale Version behalten, der
/// Sync läuft weiter). Batch-Optionen: Checkbox „Für alle übernehmen“ +
/// Button „Alle weiteren überspringen“.
/// Rückgabe: 'lokal' | 'server' | 'beide' | 'ueberspringen' | null
Future<String?> _konfliktDialog(Song lokal, Map serverSong) async {
final serverTitel = (serverSong['title'] ?? '?').toString();
final serverKuenstler = (serverSong['artist'] ?? '?').toString();
if (!mounted) return null;
var fuerAlle = false;
return showDialog<String>(
context: context,
barrierDismissible: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('⚠️ Konflikt',
style: TextStyle(color: Colors.white, fontSize: 17)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Titel oder Künstler weichen voneinander ab:',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('📱 Lokal',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
const SizedBox(height: 2),
Text(lokal.titel,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text(lokal.kuenstler,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
],
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('☁️ Server',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
const SizedBox(height: 2),
Text(serverTitel,
style: const TextStyle(color: Colors.white, fontSize: 13)),
Text(serverKuenstler,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
],
),
),
const SizedBox(height: 4),
CheckboxListTile(
value: fuerAlle,
onChanged: (v) => setDialogState(() => fuerAlle = v ?? false),
title: const Text('Für alle weiteren Konflikte übernehmen',
style: TextStyle(color: Colors.white, fontSize: 12)),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: MeloTheme.rot,
checkColor: Colors.white,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, 'lokal'),
child: const Text('Lokale behalten',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, 'beide'),
child: const Text('Beide behalten',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, 'server'),
child: const Text('Server übernehmen',
style: TextStyle(color: MeloTheme.rot)),
),
TextButton(
onPressed: () {
_konfliktBatch = 'ueberspringen';
Navigator.pop(ctx, 'ueberspringen');
},
child: const Text('Alle weiteren überspringen',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
],
),
),
).then((wahl) {
// Batch merken: gewählte Entscheidung auf alle restlichen Konflikte
// dieses Sync-Laufs anwenden.
if (wahl != null && fuerAlle && _konfliktBatch == null) {
_konfliktBatch = wahl;
}
return wahl;
});
}
void _updateSync(String phase, double progress) {
if (mounted) {
setState(() {
_syncPhase = phase;
_syncFortschritt = progress;
});
}
}
// ─── 📋 Playlists ───
Future<void> _ladePlaylists() async {
setState(() => _ladtPlaylists = true);
try {
final pls = await widget.cloud.getPlaylists();
if (mounted) {
setState(() {
_serverPlaylists = pls;
_playlistServerCount = pls.length;
});
}
} catch (e) {
MeloLogger().fehler('cloud_playlists_laden', e);
} finally {
if (mounted) setState(() => _ladtPlaylists = false);
}
}
Future<void> _playlistErstellen() async {
final ctrl = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('Neue Playlist',
style: TextStyle(color: Colors.white)),
content: TextField(
controller: ctrl,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Playlist-Name',
hintStyle: const TextStyle(color: MeloTheme.textSekundaer),
fillColor: MeloTheme.dunkel2,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
child: const Text('Erstellen'),
),
],
),
);
ctrl.dispose();
if (name != null && name.isNotEmpty) {
final result = await widget.cloud.createPlaylist(name);
if (result != null) {
_setzeStatus('Playlist "$name" erstellt', ok: true);
await _ladePlaylists();
}
}
}
Future<void> _playlistLoeschen(int id, String name) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('Playlist löschen?',
style: TextStyle(color: Colors.white)),
content: Text('"$name" wirklich löschen?',
style: const TextStyle(color: MeloTheme.textSekundaer)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Löschen',
style: TextStyle(color: MeloTheme.rot)),
),
],
),
);
if (ok == true) {
await widget.cloud.deletePlaylist(id);
await _ladePlaylists();
_setzeStatus('Playlist gelöscht', ok: true);
}
}
// ─── ⭐ Favoriten ───
Future<void> _ladeFavorites() async {
setState(() => _ladtFavorites = true);
try {
final favs = await widget.cloud.getFavorites();
if (mounted) {
setState(() {
_serverFavorites = favs;
_favServerCount = favs.length;
});
}
} catch (e) {
MeloLogger().fehler('cloud_favorites_laden', e);
} finally {
if (mounted) setState(() => _ladtFavorites = false);
}
}
// ─── Upload / Download ───
static const String _cloudChannelId = 'de.baka.melo.downloads';
static const int _cloudNotifyId = 200;
Future<void> _upload() async {
setState(() => _ladt = true);
_setzeStatus('Suche lokale Songs...');
try {
final db = DbHelper();
final dir = Directory(
'${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) {
setState(() {
_ladt = false;
_setzeStatus('Keine lokalen Songs', ok: false);
});
return;
}
final files = dir.listSync().whereType<File>().where(
(f) => f.path.endsWith('.mp3') || f.path.endsWith('.m4a'));
final fileList = files.toList();
int count = 0;
for (int i = 0; i < fileList.length; i++) {
final f = fileList[i];
_setzeStatus('Upload: ${f.path.split('/').last}...');
// Progress-Notification
if (Platform.isAndroid) {
notificationsPlugin.show(
id: _cloudNotifyId,
title: 'Melo Cloud Upload',
body: '${i + 1} / ${fileList.length}: ${f.path.split('/').last}',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_cloudChannelId,
'Melo Downloads',
channelDescription: 'Cloud Upload-Fortschritt',
importance: Importance.low,
priority: Priority.low,
onlyAlertOnce: true,
showProgress: true,
maxProgress: fileList.length,
progress: i,
ongoing: true,
autoCancel: false,
),
),
);
}
final sid = await widget.cloud.upload(f.path, f.path.split('/').last);
if (sid != null) {
count++;
// Lokalen Song mit dem Server verknüpfen (cloud_id) — Grundlage
// für den bidirektionalen Favoriten-Sync (lokal → Server).
try {
final lokalerSong = await db.songNachPfad(f.path);
if (lokalerSong?.id != null) {
await db.cloudIdSetzen(lokalerSong!.id!, sid);
}
} catch (_) {
// Verknüpfung ist nice-to-have — Upload selbst war erfolgreich
}
}
}
// Abschluss-Notification
if (Platform.isAndroid) {
notificationsPlugin.cancel(id: _cloudNotifyId);
final ok = count > 0;
notificationsPlugin.show(
id: _cloudNotifyId + 1,
title: ok ? 'Cloud Upload fertig' : 'Cloud Upload fehlgeschlagen',
body: ok
? '$count / ${fileList.length} Songs hochgeladen ✅'
: 'Kein Song konnte hochgeladen werden ❌',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_cloudChannelId,
'Melo Downloads',
channelDescription: 'Cloud Upload-Abschluss',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
),
),
);
}
await _ladeStatus();
if (mounted) {
setState(() => _ladt = false);
_setzeStatus('$count Songs hochgeladen', ok: count > 0);
MeloLogger().aktion('cloud_upload', {'count': count});
}
} catch (e) {
MeloLogger().fehler('cloud_upload_path', e);
if (Platform.isAndroid) {
notificationsPlugin.cancel(id: _cloudNotifyId);
}
if (mounted) {
setState(() {
_ladt = false;
_setzeStatus('Fehler beim Upload', ok: false);
});
}
}
}
Future<void> _download() async {
if (_syncProzessLaeuft) {
_setzeStatus('Sync läuft bereits im Hintergrund', ok: false);
return;
}
final erfolgreich = await _syncAlles();
if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben
final p = await SharedPreferences.getInstance();
final now = DateTime.now();
final zeit = _formatZeit(now);
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now.toIso8601String());
if (mounted) {
setState(() => _letzterSync = zeit);
_berechneNaechstenSync();
}
}
// ─── Status ───
Future<void> _ladeStatus() async {
final st = await widget.cloud.statusDaten();
final syncSt = await widget.cloud.syncStatus();
if (!mounted) return;
setState(() {
_serverStatusDaten = st;
_serverCount = st?['total'] ?? 0;
if (syncSt != null) {
final counts = syncSt['counts'];
if (counts != null) {
_favServerCount = counts['favorites'] ?? _favServerCount;
_playlistServerCount = counts['playlists'] ?? _playlistServerCount;
}
}
});
}
Future<void> _ladeKorrupteSongs() async {
setState(() {
_ladtKorrupt = true;
_hatGeprueft = false;
});
try {
final corrupted = await widget.cloud.getCorrupted();
if (mounted) {
setState(() {
_korrupteSongs = corrupted;
_hatGeprueft = true;
});
}
} catch (e) {
MeloLogger().fehler('cloud_corrupted_laden', e);
if (mounted) setState(() => _hatGeprueft = true);
} finally {
if (mounted) setState(() => _ladtKorrupt = false);
}
}
void _setzeStatus(String msg, {bool ok = false}) {
if (mounted) {
setState(() {
_status = msg;
_statusOk = ok;
});
}
}
// ─── ✏️ Rename Dialog ───
Future<void> _renameDialog(Map song) async {
_renameTitleCtrl.text = song['title']?.toString() ?? '';
_renameArtistCtrl.text = song['artist']?.toString() ?? '';
final sid = song['id']?.toString() ?? '';
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('Song umbenennen',
style: TextStyle(color: Colors.white)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _renameTitleCtrl,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Titel',
labelStyle:
const TextStyle(color: MeloTheme.textSekundaer),
fillColor: MeloTheme.dunkel2,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
TextField(
controller: _renameArtistCtrl,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Künstler',
labelStyle:
const TextStyle(color: MeloTheme.textSekundaer),
fillColor: MeloTheme.dunkel2,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Speichern'),
),
],
),
);
if (result == true && sid.isNotEmpty) {
final re = await widget.cloud.renameSong(sid,
title: _renameTitleCtrl.text.trim(),
artist: _renameArtistCtrl.text.trim());
if (re != null && re['status'] == 'ok') {
_setzeStatus('Song umbenannt', ok: true);
// Auch lokal in DB updaten
final db = DbHelper();
final localSong = await db.songNachCloudId(sid);
if (localSong?.id != null) {
await db.cloudMetadatenAktualisieren(localSong!.id!,
title: _renameTitleCtrl.text.trim(),
artist: _renameArtistCtrl.text.trim());
}
}
}
}
// ─── 🎨 UI ───
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: MeloTheme.schwarz,
appBar: AppBar(
backgroundColor: MeloTheme.dunkel1,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white, size: 22),
onPressed: () {
if (widget.onZurueck != null) {
widget.onZurueck!();
} else {
Navigator.pop(context);
}
},
),
title: const Row(
children: [
Text('☁️', style: TextStyle(fontSize: 20)),
SizedBox(width: 8),
Text('Cloud Sync',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600)),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
onPressed: () async {
await widget.cloud.verbinde();
await _ladeStatus();
await _ladeServerDaten();
},
),
],
),
// ListenableBuilder: Status kommt live vom CloudService
// ('Verbinde...' → 'Verbunden'/'Keine Verbindung')
body: ListenableBuilder(
listenable: widget.cloud,
builder: (context, _) =>
_syncLaeuft ? _syncFortschrittWidget() : _contentWidget(),
),
);
}
Widget _syncFortschrittWidget() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 90,
height: 90,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 90,
height: 90,
child: CircularProgressIndicator(
value: _syncFortschritt > 0 ? _syncFortschritt : null,
color: MeloTheme.rot,
strokeWidth: 4,
),
),
// Rotierende ☁️-Animation während des Syncs
RotationTransition(
turns: _syncAnimController,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
shape: BoxShape.circle,
),
child: const Icon(Icons.cloud_sync, color: MeloTheme.rot, size: 26),
),
),
],
),
),
const SizedBox(height: 20),
Text(
_syncPhase,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'${(_syncFortschritt * 100).toInt()}%',
style:
const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
if (_syncedItems > 0) ...[
const SizedBox(height: 4),
Text(
'$_syncedItems Elemente synchronisiert',
style: const TextStyle(
color: Color(0xFFA5D6A7), fontSize: 12),
),
],
const SizedBox(height: 24),
TextButton(
onPressed: () {
// Nur die UI-Persistenz ändern: Fortschritts-Ansicht schließen.
// Der Sync-Loop läuft weiter und der echte Guard
// (_syncProzessLaeuft) bleibt gesperrt, bis der Loop fertig
// ist — ein erneuter „Jetzt Syncen“ startet KEINEN Parallel-Sync.
setState(() => _syncLaeuft = false);
},
child: const Text('Im Hintergrund fortsetzen',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
],
),
);
}
Widget _contentWidget() {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ─── Status-Karte ───
_statusKarte(),
const SizedBox(height: 20),
// ─── Sync-Modus ───
_sektionsHeader('🔄 Sync-Modus'),
const SizedBox(height: 8),
_syncModusAuswahl(),
const SizedBox(height: 16),
// ─── Upload / Download Buttons ───
Row(
children: [
Expanded(
child: _aktionsButton(
icon: Icons.upload_rounded,
label: 'Upload',
beschreibung: 'Lokale Songs → Server',
onTap: _upload,
)),
const SizedBox(width: 12),
Expanded(
child: _aktionsButton(
icon: Icons.sync_rounded,
label: 'Jetzt Syncen',
beschreibung: _autoSync ? 'Sofort synchronisieren' : 'Manuell syncen',
onTap: _download,
)),
],
),
// Lade-Indikator
if (_ladt)
const Padding(
padding: EdgeInsets.only(top: 16),
child: Center(
child: CircularProgressIndicator(color: MeloTheme.rot)),
),
// Status-Text: transiente Meldungen (Upload-Fortschritt, Sync-Ergebnis)
// haben Vorrang — sonst kommt der Verbindungsstatus live vom Service
// ('Verbinde...' → 'Verbunden' / 'Keine Verbindung')
if (!_ladt) ...[
Builder(builder: (context) {
final hatTransientStatus = _status != null;
final verbindet = widget.cloud.status == CloudStatus.verbinde;
final statusOk =
hatTransientStatus ? _statusOk : widget.cloud.istVerbunden;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: statusOk
? const Color(0xFF0D3B1E)
: verbindet
? const Color(0xFF3B2A0D)
: const Color(0xFF3B0D0D),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
statusOk
? Icons.check_circle
: verbindet
? Icons.sync
: Icons.info_outline,
size: 16,
color: statusOk
? const Color(0xFF4CAF50)
: verbindet
? const Color(0xFFFFA726)
: const Color(0xFFEF5350),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_status ?? widget.cloud.statusText,
style: TextStyle(
color: statusOk
? const Color(0xFFA5D6A7)
: verbindet
? const Color(0xFFFFCC80)
: const Color(0xFFEF9A9A),
fontSize: 13,
),
),
),
],
),
),
);
}),
],
const SizedBox(height: 24),
// ─── Sync-Info ───
_sektionsHeader('📊 Sync-Info'),
const SizedBox(height: 8),
_syncInfoKarte(),
const SizedBox(height: 20),
// ─── Server-Playlisten ───
_sektionsHeader('📋 Server-Playlisten'),
const SizedBox(height: 8),
_playlistSektion(),
const SizedBox(height: 20),
// ─── Server-Favoriten ───
_sektionsHeader('⭐ Server-Favoriten'),
const SizedBox(height: 8),
_favoritenSektion(),
const SizedBox(height: 20),
// ─── Korrupte Songs ───
_sektionsHeader('⚠️ Defekte Musik (Server)'),
const SizedBox(height: 8),
_korrupteSektion(),
const SizedBox(height: 20),
// ─── Letzter Sync ───
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.history,
color: MeloTheme.textSekundaer, size: 18),
const SizedBox(width: 10),
const Text('Letzter Sync: ',
style: TextStyle(
color: MeloTheme.textSekundaer, fontSize: 13)),
Text(
_letzterSync,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
],
),
if (_naechsterSync != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.schedule,
color: MeloTheme.textSekundaer, size: 16),
const SizedBox(width: 10),
const Text('Nächster Sync: ',
style: TextStyle(
color: MeloTheme.textSekundaer, fontSize: 12)),
Text(
_naechsterSync!,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
],
),
],
],
),
),
const SizedBox(height: 32),
],
),
);
}
// ─── Widget-Bausteine ───
Widget _statusKarte() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1A0000), Color(0xFF0D0D0D)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.3)),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: MeloTheme.rot.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
),
child:
const Icon(Icons.storage_rounded, color: MeloTheme.rot, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$_serverCount',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w700,
),
),
const Text(
'Songs auf dem Server',
style: TextStyle(
color: MeloTheme.textSekundaer, fontSize: 13),
),
],
),
),
// Verbindungsstatus-Indikator (live vom CloudService)
Builder(builder: (context) {
final farbe = widget.cloud.istVerbunden
? const Color(0xFF4CAF50)
: widget.cloud.status == CloudStatus.verbinde
? const Color(0xFFFFA726)
: const Color(0xFFEF5350);
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: farbe,
boxShadow: [
BoxShadow(
color: farbe.withValues(alpha: 0.5),
blurRadius: 8,
),
],
),
);
}),
],
),
);
}
Widget _syncModusAuswahl() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Manuell / Auto Toggle
Row(
children: [
Expanded(child: _syncModusButton('Manuell', false, Icons.touch_app,
!_autoSync)),
const SizedBox(width: 4),
Expanded(child: _syncModusButton('Auto', true, Icons.sync,
_autoSync)),
],
),
// Intervall-Auswahl (nur wenn Auto aktiv)
if (_autoSync) ...[
const SizedBox(height: 4),
const Padding(
padding: EdgeInsets.only(left: 8, top: 4),
child: Text('Intervall',
style: TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 11,
fontWeight: FontWeight.w500)),
),
const SizedBox(height: 4),
Row(
children: [3, 6, 12].map((h) {
final aktiv = _syncIntervall == h;
return Expanded(
child: Padding(
padding: EdgeInsets.only(
right: h != 12 ? 4 : 0),
child: GestureDetector(
onTap: () async {
setState(() => _syncIntervall = h);
(await SharedPreferences.getInstance())
.setInt('cloud_interval', h);
_starteAutoSync();
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10),
decoration: BoxDecoration(
color: aktiv
? MeloTheme.rot
: MeloTheme.dunkel2,
borderRadius:
BorderRadius.circular(10),
),
child: Center(
child: Text(
'${h}h',
style: TextStyle(
color: aktiv
? Colors.white
: MeloTheme.textSekundaer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}).toList(),
),
],
],
),
);
}
Widget _syncModusButton(
String label, bool auto, IconData icon, bool aktiv) {
return GestureDetector(
onTap: () async {
if (auto) {
setState(() => _autoSync = true);
(await SharedPreferences.getInstance())
.setBool('cloud_auto', true);
_starteAutoSync();
_berechneNaechstenSync();
} else {
setState(() => _autoSync = false);
(await SharedPreferences.getInstance())
.setBool('cloud_auto', false);
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 14,
color: aktiv ? Colors.white : MeloTheme.textSekundaer),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
Widget _syncInfoKarte() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Statistik-Karten 2×2: Songs · Playlisten · Favoriten · Letzter Sync ──
Row(
children: [
Expanded(
child: _statistikKarte(
icon: Icons.library_music,
label: 'Songs',
wert: '$_serverCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _statistikKarte(
icon: Icons.playlist_play,
label: 'Playlisten',
wert: '$_playlistServerCount',
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _statistikKarte(
icon: Icons.favorite,
label: 'Favoriten',
wert: '$_favServerCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _statistikKarte(
icon: Icons.history,
label: 'Letzter Sync',
wert: _letzterSync,
),
),
],
),
const SizedBox(height: 14),
const Divider(color: MeloTheme.dunkel2, height: 1),
const SizedBox(height: 12),
// ── Speicheranzeige (Balken, falls Server Daten liefert) ──
_speicherZeile(),
const SizedBox(height: 12),
// ── Sync-Historie (heute) ──
_syncHistorieZeile(),
],
),
);
}
Widget _statistikKarte({
required IconData icon,
required String label,
required String wert,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: MeloTheme.rot, size: 16),
const SizedBox(height: 8),
Text(
wert,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 11),
),
],
),
);
}
/// Speicheranzeige mit Balken. Liefert der Server keine Storage-Daten
/// (Felder `storage_used`/`storage_total` o.ä.), erscheint Platzhalter „–".
Widget _speicherZeile() {
final st = _serverStatusDaten;
num? used;
num? total;
if (st != null) {
used = (st['storage_used'] ?? st['bytes_used'] ?? st['used_bytes']) as num?;
total = (st['storage_total'] ?? st['bytes_total'] ?? st['total_bytes'])
as num?;
}
final hatDaten = used != null && total != null && total > 0;
final anteil = hatDaten ? (used / total).clamp(0.0, 1.0) : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.storage,
color: MeloTheme.textSekundaer, size: 16),
const SizedBox(width: 8),
const Text('Speicher',
style: TextStyle(
color: MeloTheme.textSekundaer, fontSize: 13)),
const Spacer(),
Text(
hatDaten
? '${_formatBytes(used)} / ${_formatBytes(total)}'
: '',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: hatDaten ? anteil.toDouble() : 0,
minHeight: 6,
backgroundColor: MeloTheme.dunkel2,
valueColor: const AlwaysStoppedAnimation<Color>(MeloTheme.rot),
),
),
],
);
}
String _formatBytes(num bytes) {
if (bytes <= 0) return '0 B';
const einheiten = ['B', 'KB', 'MB', 'GB', 'TB'];
var wert = bytes.toDouble();
var i = 0;
while (wert >= 1024 && i < einheiten.length - 1) {
wert /= 1024;
i++;
}
return '${wert.toStringAsFixed(wert >= 100 ? 0 : 1)} ${einheiten[i]}';
}
/// Sync-Historie-Zeile: „Heute: 14 Dateien, 2 Favoriten, 1 Playlist“
Widget _syncHistorieZeile() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.event_note,
color: MeloTheme.textSekundaer, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_syncHistorieHeuteText,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
);
}
Widget _playlistSektion() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// "Neu" Button
Row(
children: [
Expanded(
child: GestureDetector(
onTap: _ladtPlaylists ? null : _playlistErstellen,
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
decoration: BoxDecoration(
color: MeloTheme.rot.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, color: MeloTheme.rot, size: 16),
SizedBox(width: 6),
Text('Neue Playlist',
style: TextStyle(
color: MeloTheme.rot,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _ladtPlaylists ? null : _ladePlaylists,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: _ladtPlaylists
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: MeloTheme.rot))
: const Icon(Icons.refresh,
color: MeloTheme.textSekundaer, size: 16),
),
),
],
),
if (_serverPlaylists.isNotEmpty) ...[
const SizedBox(height: 10),
const Divider(color: MeloTheme.dunkel2, height: 1),
const SizedBox(height: 8),
..._serverPlaylists.map((pl) => _playlistTile(pl)),
] else if (!_ladtPlaylists) ...[
const SizedBox(height: 10),
const Text('Keine Playlisten auf dem Server',
style: TextStyle(
color: MeloTheme.textSekundaer, fontSize: 12)),
],
],
),
);
}
Widget _playlistTile(Map pl) {
final name = pl['name']?.toString() ?? '?';
final count = pl['song_count'] ?? 0;
final id = pl['id'] as int? ?? 0;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const Icon(Icons.playlist_play, color: MeloTheme.rot, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(name,
style: const TextStyle(color: Colors.white, fontSize: 13)),
),
Text('$count Songs',
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 11)),
const SizedBox(width: 8),
GestureDetector(
onTap: () => _playlistLoeschen(id, name),
child: const Icon(Icons.delete_outline,
color: MeloTheme.textSekundaer, size: 16),
),
],
),
);
}
Widget _favoritenSektion() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.favorite, color: MeloTheme.rot, size: 16),
const SizedBox(width: 8),
Text(
'$_favServerCount Favoriten auf dem Server',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
const Spacer(),
GestureDetector(
onTap: _ladtFavorites ? null : _ladeFavorites,
child: _ladtFavorites
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: MeloTheme.rot))
: const Icon(Icons.refresh,
color: MeloTheme.textSekundaer, size: 16),
),
],
),
if (_serverFavorites.isNotEmpty) ...[
const SizedBox(height: 8),
const Divider(color: MeloTheme.dunkel2, height: 1),
const SizedBox(height: 8),
..._serverFavorites.take(5).map((f) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(Icons.music_note,
color: MeloTheme.textSekundaer, size: 14),
const SizedBox(width: 8),
Expanded(
child: Text(
f['title']?.toString() ?? '?',
style: const TextStyle(
color: Colors.white70, fontSize: 12),
),
),
// Rename Button
GestureDetector(
onTap: () => _renameDialog(f),
child: const Icon(Icons.edit,
color: MeloTheme.textSekundaer, size: 14),
),
],
),
)),
if (_serverFavorites.length > 5)
Text(
'... und ${_serverFavorites.length - 5} weitere',
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 11),
),
],
],
),
);
}
Widget _korrupteSektion() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: GestureDetector(
onTap: _ladtKorrupt ? null : _ladeKorrupteSongs,
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
decoration: BoxDecoration(
color: MeloTheme.rot.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_ladtKorrupt)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
color: MeloTheme.rot,
strokeWidth: 2,
),
)
else
const Icon(Icons.warning_amber_rounded,
color: MeloTheme.rot, size: 16),
const SizedBox(width: 8),
Text(
_ladtKorrupt ? 'Prüfe...' : 'Auf defekte Musik prüfen',
style: const TextStyle(
color: MeloTheme.rot,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
],
),
if (_korrupteSongs.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(color: MeloTheme.dunkel2, height: 1),
const SizedBox(height: 8),
Text(
'${_korrupteSongs.length} defekte Songs gefunden:',
style: const TextStyle(
color: Color(0xFFEF9A9A),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
..._korrupteSongs.map((s) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const Text('⚠️', style: TextStyle(fontSize: 13)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s['title']?.toString() ?? 'Unbekannt',
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
decoration: TextDecoration.lineThrough,
),
),
if (s['reason'] != null)
Text(
s['reason'].toString(),
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 10,
),
),
],
),
),
],
),
)),
] else if (!_ladtKorrupt && _hatGeprueft && _korrupteSongs.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Keine defekten Songs auf dem Server',
style: TextStyle(color: Color(0xFFA5D6A7), fontSize: 12),
),
),
],
),
);
}
Widget _aktionsButton({
required IconData icon,
required String label,
required String beschreibung,
required VoidCallback onTap,
}) {
return Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: _ladt ? null : onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
children: [
Icon(icon, color: MeloTheme.rot, size: 28),
const SizedBox(height: 8),
Text(label,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(beschreibung,
textAlign: TextAlign.center,
style: const TextStyle(
color: MeloTheme.textSekundaer, fontSize: 11)),
],
),
),
),
);
}
Widget _sektionsHeader(String titel) {
return Text(
titel,
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
),
);
}
}