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

1911 lines
64 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 '../services/cloud_service.dart';
import '../services/sync_service.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;
// ─── Sync-Animation ───
late final AnimationController _syncAnimController;
// ─── Sync-Einstellungen (F3: Aus/1/3/6/12h, persistiert) ───
/// 0 = Aus (nur manuell). Wird via `cloud_interval` persistiert.
int _syncIntervall = 0;
String _letzterSync = 'Nie';
String? _letzterSyncTs;
String? _naechsterSync;
// ─── Sync-Fortschritt ───
String _syncPhase = '';
double _syncFortschritt = 0;
/// UI-Flag: Fortschritts-Ansicht anzeigen („Im Hintergrund fortsetzen“
/// setzt NUR dieses Flag zurück — der Sync-Loop läuft im SyncService
/// weiter, dessen globaler Guard verhindert Parallel-Syncs).
bool _syncLaeuft = false;
int _syncedItems = 0;
int _syncGesamt = 0;
/// Zentraler Sync-Loop (F3: läuft auch ohne geöffneten Tab weiter,
/// persistente Notification + Abschluss-Benachrichtigung + Chip-Puls).
late final SyncService _sync;
// ─── 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),
);
// Zentraler Sync-Service (F3): läuft auch ohne geöffneten Tab weiter,
// persistente Notification + Abschluss-Benachrichtigung + Chip-Puls.
_sync = SyncService(widget.cloud);
_sync.onFortschritt = _updateSync;
_sync.onFortschrittZaehler = (aktuell, gesamt) {
if (mounted) {
setState(() {
_syncedItems = aktuell;
_syncGesamt = gesamt;
});
}
};
_sync.onStatus = _setzeStatus;
_sync.onHistorie = (dateien, favoriten, playlists) =>
_syncHistorieEintragen(
dateien: dateien, favoriten: favoriten, playlists: playlists);
_sync.onKonflikt = _konfliktDialog;
_sync.onFavoritenAnzahl = (anzahl) {
// NEU-1-Fix: mounted-Guard (Loop läuft evtl. ohne geöffneten Tab)
if (mounted) setState(() => _favServerCount = anzahl);
};
_sync.onPlaylistenLaden = () async {
await _ladePlaylists();
return _serverPlaylists.length;
};
_sync.onNachSync = () async {
await _ladeStatus();
await _ladeServerDaten();
_berechneNaechstenSync();
};
_sync.onSyncEnde = _syncEnde;
_sync.onBericht = _zeigeKonfliktReport;
// 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);
_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');
// F3: cloud_interval (0 = Aus). Migration von cloud_auto (alt: bool).
var intervall = p.getInt('cloud_interval');
if (intervall == null) {
intervall = (p.getBool('cloud_auto') ?? true) ? 6 : 0;
await p.setInt('cloud_interval', intervall);
}
if (mounted) {
setState(() {
_syncIntervall = intervall ?? 0;
_letzterSync = letzter ??
(letzterTs != null
? _formatZeit(DateTime.tryParse(letzterTs))
: 'Nie');
_letzterSyncTs = letzterTs;
});
}
_berechneNaechstenSync();
}
Future<void> _ladeServerDaten() async {
if (!widget.cloud.istVerbunden) return;
// Paralleles Laden
await Future.wait([
_ladePlaylists(),
_ladeFavorites(),
]);
}
void _berechneNaechstenSync() {
if (_syncIntervall == 0) {
_naechsterSync = null;
return;
}
final now = DateTime.now();
final last = _letzterSyncTs != null
? DateTime.tryParse(_letzterSyncTs!)
: null;
if (last != null) {
final next = last.add(Duration(hours: _syncIntervall));
if (next.isBefore(now)) {
_naechsterSync = 'Jetzt fällig';
} else {
_naechsterSync = _formatZeit(next);
}
} else {
_naechsterSync = null;
}
}
String _formatZeit(DateTime? dt) {
if (dt == null) return 'Nie';
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
/// Intervall-Auswahl persistieren + App-weiten Auto-Sync-Timer neu starten.
Future<void> _intervallSetzen(int stunden) async {
setState(() => _syncIntervall = stunden);
final p = await SharedPreferences.getInstance();
await p.setInt('cloud_interval', stunden);
await p.setBool('cloud_auto', stunden > 0); // Kompatibilität (altes Flag)
await SyncService.starteAutoSyncTimer();
_berechneNaechstenSync();
}
// ─── 🔄 Komplett-Sync ───
/// Komplett-Sync (F3): delegiert an den zentralen [SyncService], der auch
/// ohne geöffneten Cloud-Tab weiterläuft (persistente Notification,
/// Abschluss-Benachrichtigung, ☁️-Chip-Puls, globaler Doppel-Sync-Guard).
Future<bool> _syncAlles({bool automatisch = false}) async {
if (SyncService.laeuftGlobal) return false;
if (mounted) {
setState(() {
_syncLaeuft = true;
_syncPhase = 'Verbinde...';
_syncFortschritt = 0;
_syncedItems = 0;
_syncGesamt = 0;
});
}
_syncAnimController.repeat();
return _sync.syncAlles(automatisch: automatisch);
}
/// Immer am Loop-Ende (auch bei Fehler / nach „Im Hintergrund fortsetzen“):
/// Animation stoppen, Sync-Ansicht schließen. Controller-Zugriffe abgesichert,
/// falls der Screen während des Hintergrund-Syncs disposed wurde (MED-4).
void _syncEnde() {
try {
_syncAnimController.stop();
_syncAnimController.value = 0;
} catch (_) {
// Controller kann bereits disposed sein (Screen verlassen)
}
if (mounted) {
setState(() {
_syncLaeuft = false;
_syncPhase = '';
});
}
}
/// Konflikt-Report (Sprint E): Zusammenfassung nach langem Offline-Sync.
/// „Willkommen zurück! Seit letztem Sync: +8 neue · 3 gelöscht · ⭐5“
void _zeigeKonfliktReport(SyncBericht bericht) {
if (!mounted) return;
final titel = 'Willkommen zurück!';
final zeilen = <String>[
'Seit deinem letzten Sync:',
if (bericht.neueSongs > 0) ' ${bericht.neueSongs} neue Songs',
if (bericht.geloeschteSongs > 0) '🗑️ ${bericht.geloeschteSongs} gelöscht',
if (bericht.favoritenGeaendert > 0)
'⭐ ${bericht.favoritenGeaendert} Favoriten geändert',
];
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Text(titel,
style: const TextStyle(color: Colors.white, fontSize: 18)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: zeilen
.map((z) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Text(z,
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 14)),
))
.toList(),
),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
if (bericht.details.isNotEmpty) {
_zeigeReportDetails(bericht);
}
},
child: const Text('Details',
style: TextStyle(color: MeloTheme.rot)),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK',
style: TextStyle(color: Colors.white)),
),
],
),
);
}
void _zeigeReportDetails(SyncBericht bericht) {
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: MeloTheme.dunkel1,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Änderungen',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Flexible(
child: ListView(
shrinkWrap: true,
children: bericht.details
.map((d) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(d,
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 13)),
))
.toList(),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Schließen',
style: TextStyle(color: MeloTheme.rot)),
),
),
],
),
),
),
);
}
// ─── 📜 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: [KonfliktErgebnis] ('lokal' | 'server' | 'beide' |
/// 'ueberspringen') oder null bei Abbruch. Die Batch-Logik übernimmt der
/// SyncService (MED-3) — der Dialog liefert nur wahl + fuerAlle.
Future<KonfliktErgebnis?> _konfliktDialog(Song lokal, Map serverSong) async {
final serverTitel = (serverSong['title'] ?? '?').toString();
final serverKuenstler = (serverSong['artist'] ?? '?').toString();
if (!mounted) return null;
var fuerAlle = false;
final wahl = await 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: () => Navigator.pop(ctx, 'ueberspringen'),
child: const Text('Alle weiteren überspringen',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
],
),
),
);
if (wahl == null) return null;
// 'ueberspringen' = alle weiteren überspringen → fuerAlle=true, damit der
// SyncService die Batch-Entscheidung (keep local) übernimmt.
return KonfliktErgebnis(wahl, fuerAlle: wahl == 'ueberspringen' || fuerAlle);
}
void _updateSync(String phase, double progress) {
if (mounted) {
setState(() {
_syncPhase = phase;
_syncFortschritt = progress;
});
}
}
// ─── 📋 Playlists ───
Future<void> _ladePlaylists() async {
if (!mounted) return; // NEU-1: Loop kann ohne geöffneten Tab laufen
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 {
if (!mounted) return; // NEU-1: Loop kann ohne geöffneten Tab laufen
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 (SyncService.laeuftGlobal) {
_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(
_syncGesamt > 0
? '$_syncedItems / $_syncGesamt synchronisiert'
: '$_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 im SyncService weiter und der globale
// Guard (SyncService.laeuftGlobal) bleibt gesperrt, bis der
// Loop fertig ist — ein erneuter „Jetzt Syncen“ startet KEINEN
// Parallel-Sync. Sichtbar bleibt er via Notification + Chip-Puls.
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: _syncIntervall > 0
? '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,
),
],
),
);
}),
],
),
);
}
/// Auto-Sync-Intervall-Auswahl (F3): Aus/1h/3h/6h/12h.
/// 0 = Aus (nur manuell) — ersetzt den alten Manuell/Auto-Toggle.
Widget _syncModusAuswahl() {
const optionen = [0, 1, 3, 6, 12];
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: [
const Padding(
padding: EdgeInsets.only(left: 8, top: 4),
child: Text('Auto-Sync-Intervall',
style: TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 11,
fontWeight: FontWeight.w500)),
),
const SizedBox(height: 4),
Row(
children: optionen.map((h) {
final aktiv = _syncIntervall == h;
final label = h == 0 ? 'Aus' : '${h}h';
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: h != 12 ? 4 : 0),
child: GestureDetector(
onTap: () => _intervallSetzen(h),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
label,
style: TextStyle(
color: aktiv
? Colors.white
: MeloTheme.textSekundaer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}).toList(),
),
],
),
);
}
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,
),
);
}
}