diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index a0d41a9..43af903 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; @@ -9,6 +10,7 @@ import '../utils/sanitize.dart'; import '../services/cloud_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 @@ -22,16 +24,21 @@ class CloudScreen extends StatefulWidget { State createState() => _CloudScreenState(); } -class _CloudScreenState extends State { +class _CloudScreenState extends State + 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 ─── bool _autoSync = true; int _syncIntervall = 6; @@ -45,6 +52,9 @@ class _CloudScreenState extends State { bool _syncLaeuft = false; int _syncedItems = 0; + // ─── Sync-Historie (heute) ─── + List> _syncHistorie = []; + // ─── Korrupt ─── List _korrupteSongs = []; bool _ladtKorrupt = false; @@ -65,9 +75,14 @@ class _CloudScreenState extends State { @override void initState() { super.initState(); + _syncAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); // Verbindung stellt der CloudService beim App-Start her (MeloHome.initState). // Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist. _ladeSettings(); + _ladeSyncHistorie(); if (widget.cloud.istVerbunden) { _serverDatenGeladen = true; _ladeServerDaten(); @@ -84,6 +99,7 @@ class _CloudScreenState extends State { void dispose() { widget.cloud.removeListener(_onCloudStatus); _syncTimer?.cancel(); + _syncAnimController.dispose(); _renameTitleCtrl.dispose(); _renameArtistCtrl.dispose(); super.dispose(); @@ -188,6 +204,7 @@ class _CloudScreenState extends State { _syncFortschritt = 0; _syncedItems = 0; }); + _syncAnimController.repeat(); try { // Phase 1: Songs synchronisieren @@ -209,7 +226,7 @@ class _CloudScreenState extends State { if (existing == null) totalNew++; } - // Downloade neue Songs + // Downloade neue Songs + löse Konflikte (Titel lokal ≠ Server) int processed = 0; for (final song in serverSongs) { final sid = song['id']?.toString() ?? ''; @@ -217,6 +234,41 @@ class _CloudScreenState extends State { final title = (song['title'] ?? 'unknown').toString(); final existing = await db.songNachCloudId(sid); if (existing != null) { + // Konfliktprüfung: Server-Titel weicht vom lokalen ab + final serverTitel = title.trim(); + final lokalerTitel = existing.titel.trim(); + if (serverTitel.isNotEmpty && + lokalerTitel.toLowerCase() != serverTitel.toLowerCase()) { + final wahl = await _konfliktDialog(existing, song); + if (wahl == 'server') { + await db.cloudMetadatenAktualisieren( + existing.id!, + title: serverTitel, + artist: (song['artist']?.toString() ?? '').trim(), + ); + _syncedItems++; + } else if (wahl == 'beide') { + // Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen + final safeTitle = sanitizeDateiname(serverTitel); + var dest = '${dir.path}/$safeTitle'; + if (await File(dest).exists()) { + dest = '${dir.path}/$safeTitle (Server)'; + } + if (await widget.cloud.download(sid, dest)) { + await db.songEinfuegen(Song( + titel: serverTitel, + kuenstler: (song['artist']?.toString() ?? '').trim(), + dauerSekunden: 0, + dateiPfad: dest, + downloadQuelle: 'cloud', + istHeruntergeladen: true, + )); + downloaded++; + _syncedItems = downloaded; + } + } + // 'lokal' → nichts tun (lokale Version behalten) + } processed++; continue; } @@ -266,6 +318,12 @@ class _CloudScreenState extends State { _setzeStatus( '$downloaded Songs + ${favIds.length} Favoriten synchronisiert', ok: true); + // Sync-Historie fürs Dashboard festhalten + await _syncHistorieEintragen( + dateien: downloaded, + favoriten: favIds.length, + playlists: _serverPlaylists.length, + ); await _ladeStatus(); } MeloLogger().aktion('cloud_sync_all', { @@ -277,6 +335,8 @@ class _CloudScreenState extends State { MeloLogger().fehler('cloud_sync_all', e); _setzeStatus('Sync-Fehler: $e', ok: false); } finally { + _syncAnimController.stop(); + _syncAnimController.value = 0; if (mounted) { setState(() { _syncLaeuft = false; @@ -286,6 +346,166 @@ class _CloudScreenState extends State { } } + // ─── 📜 Sync-Historie (fürs Dashboard) ─── + + static const _historieKey = 'cloud_sync_history'; + static const _historieCap = 30; + + Future _ladeSyncHistorie() async { + final p = await SharedPreferences.getInstance(); + final roh = p.getStringList(_historieKey) ?? []; + final eintraege = >[]; + for (final s in roh) { + try { + final m = jsonDecode(s) as Map; + eintraege.add(m); + } catch (_) { + // kaputte Einträge ignorieren + } + } + if (mounted) setState(() => _syncHistorie = eintraege); + } + + Future _syncHistorieEintragen({ + required int dateien, + required int favoriten, + required int playlists, + }) async { + final eintrag = { + '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 = [ + 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 lokal ≠ Server. NUR wenn ein Konflikt existiert. + /// Rückgabe: 'lokal' | 'server' | 'beide' | null (abgebrochen) + Future _konfliktDialog(Song lokal, Map serverSong) async { + final serverTitel = (serverSong['title'] ?? '?').toString(); + final serverKuenstler = (serverSong['artist'] ?? '?').toString(); + if (!mounted) return null; + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => 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 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)), + ], + ), + ), + ], + ), + 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)), + ), + ], + ), + ); + } + void _updateSync(String phase, double progress) { if (mounted) { setState(() { @@ -521,6 +741,7 @@ class _CloudScreenState extends State { final syncSt = await widget.cloud.syncStatus(); if (!mounted) return; setState(() { + _serverStatusDaten = st; _serverCount = st?['total'] ?? 0; if (syncSt != null) { @@ -698,12 +919,34 @@ class _CloudScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ SizedBox( - width: 80, - height: 80, - child: CircularProgressIndicator( - value: _syncFortschritt > 0 ? _syncFortschritt : null, - color: MeloTheme.rot, - strokeWidth: 4, + 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), @@ -1135,35 +1378,179 @@ class _CloudScreenState extends State { border: Border.all(color: MeloTheme.dunkel2), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _syncInfoZeile('Songs auf Server', '$_serverCount'), - _syncInfoZeile('Server-Favoriten', '$_favServerCount'), - _syncInfoZeile('Server-Playlisten', '$_playlistServerCount'), - _syncInfoZeile('Sync-Modus', _autoSync ? 'Automatisch (${_syncIntervall}h)' : 'Manuell'), + // ── 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 _syncInfoZeile(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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: [ - Text(label, - style: const TextStyle( - color: MeloTheme.textSekundaer, fontSize: 13)), - Text(value, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500)), + 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(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,