1922 lines
65 KiB
Dart
1922 lines
65 KiB
Dart
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;
|
||
// LOW-1: „Willkommen zurück!“ nur zeigen, wenn der letzte Sync mehr als
|
||
// 24h zurückliegt (oder noch nie gesynct) — sonst Dialog-Flut bei jedem
|
||
// Sync mit Änderungen. Basis ist der letzterSync-Stand VOR diesem Lauf.
|
||
if (!bericht.nachLangerPause()) 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; // SyncService persistiert cloud_last_sync nur bei Erfolg
|
||
// UI: letzten Sync anzeigen. KEINE zweite cloud_last_sync_ts-Schreibung
|
||
// mit now-am-Ende hier — SyncService persistiert bereits den
|
||
// Sync-BEGINN-Snapshot (MED-1: Tombstone-Race). Eine spätere Schreibung
|
||
// würde die Löschungen zwischen listSongs und Sync-Ende wieder als
|
||
// „bereits gesehen“ markieren.
|
||
final p = await SharedPreferences.getInstance();
|
||
final letzterTs = p.getString('cloud_last_sync_ts');
|
||
if (mounted) {
|
||
setState(() {
|
||
_letzterSyncTs = letzterTs;
|
||
_letzterSync = letzterTs != null
|
||
? _formatZeit(DateTime.tryParse(letzterTs) ?? DateTime.now())
|
||
: _formatZeit(DateTime.now());
|
||
_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,
|
||
),
|
||
);
|
||
}
|
||
}
|