diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index 1374451..ef4fcfc 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -6,10 +6,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../utils/farb_theme.dart'; -import '../utils/sanitize.dart'; import '../services/cloud_service.dart'; -import '../services/favoriten_service.dart'; -import '../services/favoriten_sync.dart'; +import '../services/sync_service.dart'; import '../database/db_helper.dart'; import '../services/melo_logger.dart'; import '../models/song.dart'; @@ -38,35 +36,29 @@ class _CloudScreenState extends State bool _statusOk = false; bool _serverDatenGeladen = false; - // ─── Favoriten (bidirektionaler Sync) ─── - final FavoritenService _favoriten = FavoritenService(); - // ─── Sync-Animation ─── late final AnimationController _syncAnimController; - // ─── Sync-Einstellungen ─── - bool _autoSync = true; - int _syncIntervall = 6; - Timer? _syncTimer; + // ─── 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). + /// setzt NUR dieses Flag zurück — der Sync-Loop läuft im SyncService + /// weiter, dessen globaler Guard verhindert Parallel-Syncs). bool _syncLaeuft = false; - /// Echter Sync-Guard: bleibt true, solange der Sync-Loop tatsächlich läuft - /// (auch im Hintergrund nach „Im Hintergrund fortsetzen“). Erst nach dem - /// echten Loop-Ende in `finally` wird er freigegeben → kein Doppel-Sync. - bool _syncProzessLaeuft = false; int _syncedItems = 0; + int _syncGesamt = 0; - // ─── Konflikt-Batch (MED-3) ─── - /// Batch-Entscheidung für alle weiteren Konflikte dieses Sync-Laufs: - /// 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (jeden fragen) - String? _konfliktBatch; + /// 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> _syncHistorie = []; @@ -95,6 +87,37 @@ class _CloudScreenState extends State 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; // Verbindung stellt der CloudService beim App-Start her (MeloHome.initState). // Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist. _ladeSettings(); @@ -114,7 +137,6 @@ class _CloudScreenState extends State @override void dispose() { widget.cloud.removeListener(_onCloudStatus); - _syncTimer?.cancel(); _syncAnimController.dispose(); _renameTitleCtrl.dispose(); _renameArtistCtrl.dispose(); @@ -138,18 +160,23 @@ class _CloudScreenState extends State 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(() { - _autoSync = p.getBool('cloud_auto') ?? true; - _syncIntervall = p.getInt('cloud_interval') ?? 6; + _syncIntervall = intervall ?? 0; _letzterSync = letzter ?? (letzterTs != null ? _formatZeit(DateTime.tryParse(letzterTs)) : 'Nie'); + _letzterSyncTs = letzterTs; }); } _berechneNaechstenSync(); - _starteAutoSync(); } Future _ladeServerDaten() async { @@ -162,14 +189,14 @@ class _CloudScreenState extends State } void _berechneNaechstenSync() { - if (!_autoSync || _syncIntervall == 0) { + if (_syncIntervall == 0) { _naechsterSync = null; return; } final now = DateTime.now(); - final last = _letzterSync != 'Nie' - ? DateTime.tryParse(_letzterSync) - : now; + final last = _letzterSyncTs != null + ? DateTime.tryParse(_letzterSyncTs!) + : null; if (last != null) { final next = last.add(Duration(hours: _syncIntervall)); if (next.isBefore(now)) { @@ -177,6 +204,8 @@ class _CloudScreenState extends State } else { _naechsterSync = _formatZeit(next); } + } else { + _naechsterSync = null; } } @@ -185,226 +214,51 @@ class _CloudScreenState extends State return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } - void _starteAutoSync() { - _syncTimer?.cancel(); - if (!_autoSync || _syncIntervall == 0) return; - _syncTimer = Timer.periodic( - Duration(hours: _syncIntervall), - (_) => _autoSyncDurchfuehren(), - ); - } - - Future _autoSyncDurchfuehren() async { - if (_syncProzessLaeuft) return; - final erfolgreich = await _syncAlles(automatisch: true); - if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben + /// Intervall-Auswahl persistieren + App-weiten Auto-Sync-Timer neu starten. + Future _intervallSetzen(int stunden) async { + setState(() => _syncIntervall = stunden); 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(); - } + 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. [automatisch]=true (Auto-Sync-Timer): Konflikte werden - /// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still. - /// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein - /// Sync läuft (_syncProzessLaeuft-Guard). + /// 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 _syncAlles({bool automatisch = false}) async { - if (_syncProzessLaeuft) return false; - _syncProzessLaeuft = true; - _konfliktBatch = null; + 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 { - // Phase 1: Songs synchronisieren - _updateSync('Vergleiche Songs...', 0.1); - final serverSongs = await widget.cloud.listSongs(); - final db = DbHelper(); - final dir = - Directory('${(await getApplicationDocumentsDirectory()).path}/music'); - if (!await dir.exists()) await dir.create(recursive: true); - - int downloaded = 0; - int totalNew = 0; - - // Zähle neue Songs - for (final song in serverSongs) { - final sid = song['id']?.toString() ?? ''; - if (sid.isEmpty) continue; - final existing = await db.songNachCloudId(sid); - if (existing == null) totalNew++; - } - - // Downloade neue Songs + löse Konflikte (Titel lokal ≠ Server) - int processed = 0; - for (final song in serverSongs) { - final sid = song['id']?.toString() ?? ''; - if (sid.isEmpty) continue; - final title = (song['title'] ?? 'unknown').toString(); - final existing = await db.songNachCloudId(sid); - if (existing != null) { - // Konfliktprüfung: Server-Titel ODER Server-Künstler weichen ab - final serverTitel = title.trim(); - final lokalerTitel = existing.titel.trim(); - final serverKuenstler = (song['artist']?.toString() ?? '').trim(); - final lokalerKuenstler = existing.kuenstler.trim(); - final titelWeichtAb = serverTitel.isNotEmpty && - lokalerTitel.toLowerCase() != serverTitel.toLowerCase(); - final kuenstlerWeichtAb = serverKuenstler.isNotEmpty && - lokalerKuenstler.toLowerCase() != serverKuenstler.toLowerCase(); - if (titelWeichtAb || kuenstlerWeichtAb) { - // Batch: einmal gewählt → für alle weiteren Konflikte anwenden - String? wahl; - final batch = _konfliktBatch; - if (batch != null) { - wahl = batch == 'ueberspringen' ? 'lokal' : batch; - } else if (automatisch) { - // Auto-Sync: keine Dialoge — Server-Metadaten gewinnen still - wahl = 'server'; - } else { - wahl = await _konfliktDialog(existing, song); - } - if (wahl == 'server') { - await db.cloudMetadatenAktualisieren( - existing.id!, - title: serverTitel, - artist: serverKuenstler, - ); - _syncedItems++; - downloaded++; - } else if (wahl == 'beide') { - // Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen - final safeTitle = sanitizeDateiname(serverTitel); - var dest = '${dir.path}/$safeTitle'; - if (await File(dest).exists()) { - dest = '${dir.path}/$safeTitle (Server)'; - } - if (await widget.cloud.download(sid, dest)) { - await db.songEinfuegen(Song( - titel: serverTitel, - kuenstler: (song['artist']?.toString() ?? '').trim(), - dauerSekunden: 0, - dateiPfad: dest, - downloadQuelle: 'cloud', - istHeruntergeladen: true, - )); - downloaded++; - _syncedItems++; - } - } - // 'lokal' → nichts tun (lokale Version behalten) - } - processed++; - continue; - } - - _updateSync('Download: $title...', - 0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1))); - final safeTitle = sanitizeDateiname(title); - final dest = '${dir.path}/$safeTitle'; - if (await widget.cloud.download(sid, dest)) { - downloaded++; - // In DB eintragen mit cloud_id - // (vereinfacht: ID3-Reader würde Titel extrahieren) - } - processed++; - _syncedItems++; - await Future.delayed( - const Duration(milliseconds: 50)); // UI-Update erlauben - } - - // Phase 2: Favoriten bidirektional synchronisieren - // Root-Cause-Fix (Sprint D): Vorher wurden die Server-Favoriten nur - // zurückgespiegelt (Server → Server) — lokale ⭐-Toggles gingen - // verloren. Jetzt: Vereinigung lokal ∪ Server, Push + lokales Markieren. - _updateSync('Sync Favoriten…', 0.55); - final serverFavs = await widget.cloud.getFavorites(); - final serverIds = serverFavs - .map((f) => f['id']?.toString() ?? '') - .where((id) => id.isNotEmpty) - .toSet(); - final lokalIds = await _favoriten.favoritenCloudIds(); - final merged = favoritenMerge(lokal: lokalIds, server: serverIds); - // Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten - // bleiben erhalten (kein Datenverlust in beide Richtungen) - await widget.cloud.syncFavorites(merged); - // Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song - // lokal existiert); `server_favorites` spiegelt den Merge-Zustand - for (final cid in serverIds) { - await _favoriten.merkeCloudFavorit(cid); - } - await db.serverFavoritesSet(merged); - setState(() => _favServerCount = merged.length); - _syncedItems += merged.length; - - // Phase 3: Playlisten abgleichen - _updateSync('Lade Playlisten...', 0.7); - await _ladePlaylists(); - - // Phase 4: Sync-Metadaten aktualisieren - _updateSync('Speichere Sync-Zeitpunkt...', 0.9); - await widget.cloud.syncAll(); - final now = DateTime.now().toIso8601String(); - await db.syncMetaSet('last_full_sync', now); - - _updateSync('Fertig!', 1.0); - if (mounted) { - setState(() { - _syncedItems = downloaded + merged.length; - }); - _setzeStatus( - '$downloaded Songs + ${merged.length} Favoriten synchronisiert', - ok: true); - // Sync-Historie fürs Dashboard festhalten - await _syncHistorieEintragen( - dateien: downloaded, - favoriten: merged.length, - playlists: _serverPlaylists.length, - ); - await _ladeStatus(); - } - MeloLogger().aktion('cloud_sync_all', { - 'downloaded': downloaded, - 'favorites': merged.length, - 'playlists': _serverPlaylists.length, + _syncAnimController.stop(); + _syncAnimController.value = 0; + } catch (_) { + // Controller kann bereits disposed sein (Screen verlassen) + } + if (mounted) { + setState(() { + _syncLaeuft = false; + _syncPhase = ''; }); - return true; - } catch (e) { - MeloLogger().fehler('cloud_sync_all', e); - _setzeStatus('Sync-Fehler: $e', ok: false); - return false; - } finally { - // Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während - // des Syncs verlassen wurde („Im Hintergrund fortsetzen“). - _syncProzessLaeuft = false; - try { - _syncAnimController.stop(); - _syncAnimController.value = 0; - } catch (_) { - // Controller kann bereits disposed sein (Screen verlassen) - } - if (mounted) { - setState(() { - _syncLaeuft = false; - _syncPhase = ''; - }); - } } } @@ -486,13 +340,15 @@ class _CloudScreenState extends State /// Dismissable (Tap außerhalb = Abbruch → lokale Version behalten, der /// Sync läuft weiter). Batch-Optionen: Checkbox „Für alle übernehmen“ + /// Button „Alle weiteren überspringen“. - /// Rückgabe: 'lokal' | 'server' | 'beide' | 'ueberspringen' | null - Future _konfliktDialog(Song lokal, Map serverSong) async { + /// 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 _konfliktDialog(Song lokal, Map serverSong) async { final serverTitel = (serverSong['title'] ?? '?').toString(); final serverKuenstler = (serverSong['artist'] ?? '?').toString(); if (!mounted) return null; var fuerAlle = false; - return showDialog( + final wahl = await showDialog( context: context, barrierDismissible: true, builder: (ctx) => StatefulBuilder( @@ -581,24 +437,18 @@ class _CloudScreenState extends State style: TextStyle(color: MeloTheme.rot)), ), TextButton( - onPressed: () { - _konfliktBatch = 'ueberspringen'; - Navigator.pop(ctx, 'ueberspringen'); - }, + onPressed: () => Navigator.pop(ctx, 'ueberspringen'), child: const Text('Alle weiteren überspringen', style: TextStyle(color: MeloTheme.textSekundaer)), ), ], ), ), - ).then((wahl) { - // Batch merken: gewählte Entscheidung auf alle restlichen Konflikte - // dieses Sync-Laufs anwenden. - if (wahl != null && fuerAlle && _konfliktBatch == null) { - _konfliktBatch = wahl; - } - return wahl; - }); + ); + 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) { @@ -613,6 +463,7 @@ class _CloudScreenState extends State // ─── 📋 Playlists ─── Future _ladePlaylists() async { + if (!mounted) return; // NEU-1: Loop kann ohne geöffneten Tab laufen setState(() => _ladtPlaylists = true); try { final pls = await widget.cloud.getPlaylists(); @@ -706,6 +557,7 @@ class _CloudScreenState extends State // ─── ⭐ Favoriten ─── Future _ladeFavorites() async { + if (!mounted) return; // NEU-1: Loop kann ohne geöffneten Tab laufen setState(() => _ladtFavorites = true); try { final favs = await widget.cloud.getFavorites(); @@ -829,7 +681,7 @@ class _CloudScreenState extends State } Future _download() async { - if (_syncProzessLaeuft) { + if (SyncService.laeuftGlobal) { _setzeStatus('Sync läuft bereits im Hintergrund', ok: false); return; } @@ -1075,7 +927,9 @@ class _CloudScreenState extends State if (_syncedItems > 0) ...[ const SizedBox(height: 4), Text( - '$_syncedItems Elemente synchronisiert', + _syncGesamt > 0 + ? '$_syncedItems / $_syncGesamt synchronisiert' + : '$_syncedItems Elemente synchronisiert', style: const TextStyle( color: Color(0xFFA5D6A7), fontSize: 12), ), @@ -1084,9 +938,10 @@ class _CloudScreenState extends State TextButton( onPressed: () { // Nur die UI-Persistenz ändern: Fortschritts-Ansicht schließen. - // Der Sync-Loop läuft weiter und der echte Guard - // (_syncProzessLaeuft) bleibt gesperrt, bis der Loop fertig - // ist — ein erneuter „Jetzt Syncen“ startet KEINEN Parallel-Sync. + // 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', @@ -1128,7 +983,9 @@ class _CloudScreenState extends State child: _aktionsButton( icon: Icons.sync_rounded, label: 'Jetzt Syncen', - beschreibung: _autoSync ? 'Sofort synchronisieren' : 'Manuell syncen', + beschreibung: _syncIntervall > 0 + ? 'Sofort synchronisieren' + : 'Manuell syncen', onTap: _download, )), ], @@ -1359,7 +1216,10 @@ class _CloudScreenState extends State ); } + /// 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( @@ -1370,119 +1230,53 @@ class _CloudScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Manuell / Auto Toggle - Row( - children: [ - Expanded(child: _syncModusButton('Manuell', false, Icons.touch_app, - !_autoSync)), - const SizedBox(width: 4), - Expanded(child: _syncModusButton('Auto', true, Icons.sync, - _autoSync)), - ], + const Padding( + padding: EdgeInsets.only(left: 8, top: 4), + child: Text('Auto-Sync-Intervall', + style: TextStyle( + color: MeloTheme.textSekundaer, + fontSize: 11, + fontWeight: FontWeight.w500)), ), - // Intervall-Auswahl (nur wenn Auto aktiv) - if (_autoSync) ...[ - const SizedBox(height: 4), - const Padding( - padding: EdgeInsets.only(left: 8, top: 4), - child: Text('Intervall', - style: TextStyle( - color: MeloTheme.textSekundaer, - fontSize: 11, - fontWeight: FontWeight.w500)), - ), - const SizedBox(height: 4), - Row( - children: [3, 6, 12].map((h) { - final aktiv = _syncIntervall == h; - return Expanded( - child: Padding( - padding: EdgeInsets.only( - right: h != 12 ? 4 : 0), - child: GestureDetector( - onTap: () async { - setState(() => _syncIntervall = h); - (await SharedPreferences.getInstance()) - .setInt('cloud_interval', h); - _starteAutoSync(); - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 10), - decoration: BoxDecoration( - color: aktiv - ? MeloTheme.rot - : MeloTheme.dunkel2, - borderRadius: - BorderRadius.circular(10), - ), - child: Center( - child: Text( - '${h}h', - style: TextStyle( - color: aktiv - ? Colors.white - : MeloTheme.textSekundaer, - fontSize: 12, - fontWeight: FontWeight.w600, - ), + 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(), - ), - ], + ), + ); + }).toList(), + ), ], ), ); } - Widget _syncModusButton( - String label, bool auto, IconData icon, bool aktiv) { - return GestureDetector( - onTap: () async { - if (auto) { - setState(() => _autoSync = true); - (await SharedPreferences.getInstance()) - .setBool('cloud_auto', true); - _starteAutoSync(); - _berechneNaechstenSync(); - } else { - setState(() => _autoSync = false); - (await SharedPreferences.getInstance()) - .setBool('cloud_auto', false); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: aktiv ? MeloTheme.rot : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, - size: 14, - color: aktiv ? Colors.white : MeloTheme.textSekundaer), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - color: aktiv ? Colors.white : MeloTheme.textSekundaer, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - } - Widget _syncInfoKarte() { return Container( width: double.infinity, diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 0000000..4707ea9 --- /dev/null +++ b/lib/services/sync_service.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.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 '../database/db_helper.dart'; +import '../models/song.dart'; +import '../utils/sanitize.dart'; +import 'cloud_service.dart'; +import 'favoriten_service.dart'; +import 'favoriten_sync.dart'; +import 'melo_logger.dart'; +import '../main.dart'; // notificationsPlugin + +/// Ergebnis des Konflikt-Dialogs (UI-Callback aus dem Cloud-Tab): +/// [wahl] = 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (Abbruch). +/// [fuerAlle] = Checkbox „Für alle weiteren Konflikte übernehmen“. +class KonfliktErgebnis { + final String? wahl; + final bool fuerAlle; + const KonfliktErgebnis(this.wahl, {this.fuerAlle = false}); +} + +/// Zentraler Cloud-Sync (Sprint D, v2.52.2). +/// +/// Der komplette Sync-Loop (Songs → Favoriten-Merge → Playlisten → Metadaten) +/// ist hier herausgelöst aus dem CloudScreen, damit er auch OHNE geöffneten +/// Cloud-Tab laufen kann (Auto-Sync-Timer, app-weit) und nach +/// „Im Hintergrund fortsetzen“ sichtbar bleibt: +/// +/// - **Persistente Sync-Notification** („Synchronisiere… 12/25“) mit Fortschritt +/// - **Abschluss-Notification** („Sync fertig: X Songs, Y Favoriten“, tippbar) +/// - **☁️-Musikserver-Chip pulsiert** während des Syncs ([laeuftNotifier]) +/// - **Echter Doppel-Sync-Guard** ([laeuftGlobal]) über alle Instanzen hinweg +/// - **Auto-Sync-Intervall** Aus/1h/3h/6h/12h ([starteAutoSyncTimer]) — +/// KEIN Sync beim App-Start, nur wenn der letzte Sync älter als das +/// Intervall ist ([istSyncFaellig]). +/// +/// UI-Feedback läuft über Callbacks ([onFortschritt], [onStatus], …) — ohne +/// UI (Hintergrund/Auto-Sync) sind sie No-ops, die Notifications übernehmen. +class SyncService { + SyncService(this.cloud) + : _db = DbHelper(), + _favoriten = FavoritenService(); + + final CloudService cloud; + final DbHelper _db; + final FavoritenService _favoriten; + + // ─── Globale Laufzeit-Signale ─── + + /// Echter Doppel-Sync-Guard: bleibt true, solange IRGENDEIN Sync-Loop läuft + /// (auch im Hintergrund nach „Im Hintergrund fortsetzen“ oder via + /// Auto-Sync-Timer). Statisch → alle SyncService-Instanzen teilen ihn. + static bool _laeuftGlobal = false; + static bool get laeuftGlobal => _laeuftGlobal; + + /// UI-Signal für den ☁️-Musikserver-Chip (pulsiert während des Syncs). + /// ValueNotifier, damit der Home-Screen (ListenableBuilder) mitrebuildet. + static final ValueNotifier laeuftNotifier = ValueNotifier(false); + + // ─── Auto-Sync-Timer (app-weit, F3) ─── + + static Timer? _autoSyncTimer; + + /// Startet den Auto-Sync-Timer neu anhand der gespeicherten Einstellung + /// `cloud_interval` (0 = Aus). Erster Tick erst NACH dem Intervall — + /// kein Sync beim App-Start. Wird beim App-Start und bei jeder + /// Intervall-Änderung aufgerufen. + static Future starteAutoSyncTimer() async { + _autoSyncTimer?.cancel(); + _autoSyncTimer = null; + final p = await SharedPreferences.getInstance(); + final intervall = p.getInt('cloud_interval') ?? 0; + if (intervall <= 0) return; + _autoSyncTimer = Timer.periodic(Duration(hours: intervall), (_) { + final service = SyncService(CloudService()); + service._autoSyncTick(intervall); + }); + } + + /// F3c: Auto-Sync nur wenn der letzte Sync älter als das Intervall ist. + /// [letzterSync] = null (nie gesynct) → fällig. + static bool istSyncFaellig(DateTime? letzterSync, int intervallStunden, + {DateTime? jetzt}) { + if (intervallStunden <= 0) return false; + final j = jetzt ?? DateTime.now(); + final l = letzterSync; + if (l == null) return true; + return j.difference(l).inHours >= intervallStunden; + } + + Future _autoSyncTick(int intervallStunden) async { + if (_laeuftGlobal) return; + final p = await SharedPreferences.getInstance(); + final lastTs = p.getString('cloud_last_sync_ts'); + final letzter = lastTs != null ? DateTime.tryParse(lastTs) : null; + if (!istSyncFaellig(letzter, intervallStunden)) return; + MeloLogger().aktion('auto_sync_timer', {'intervall': intervallStunden}); + await syncAlles(automatisch: true); + } + + // ─── UI-Callbacks (CloudScreen verdrahtet sie; ohne UI = No-op) ─── + + /// Phase + Fortschritt (0.0–1.0) für die Fortschritts-Ansicht im Cloud-Tab. + void Function(String phase, double progress)? onFortschritt; + + /// „12/25“-Zähler (aktuelle Position / Gesamtanzahl). + void Function(int aktuell, int gesamt)? onFortschrittZaehler; + + /// Transiente Status-Meldung (grün/rot). + void Function(String msg, {bool ok})? onStatus; + + /// Sync-Historie-Eintrag (Dateien, Favoriten, Playlisten). + void Function(int dateien, int favoriten, int playlists)? onHistorie; + + /// Konflikt-Dialog (nur bei manuellem Sync — Auto-Sync gewinnt still). + Future Function(Song lokal, Map serverSong)? onKonflikt; + + /// Server-Favoriten-Anzahl nach dem Merge. + void Function(int anzahl)? onFavoritenAnzahl; + + /// Playlisten vom Server neu laden (für Sync-Phase 3 + Historie). + /// Rückgabe: Anzahl der geladenen Playlisten. + Future Function()? onPlaylistenLaden; + + /// Nach erfolgreichem Sync: Status/Playlisten/Favoriten/letzten Sync neu laden. + Future Function()? onNachSync; + + /// Immer am Ende (auch bei Fehler): Animation stoppen, Sync-Ansicht schließen. + void Function()? onSyncEnde; + + // ─── Notifications ─── + + static const int _syncNotifyId = 300; + static const String _syncChannelId = 'de.baka.melo.sync'; + + /// Komplett-Sync. [automatisch]=true (Auto-Sync-Timer): Konflikte werden + /// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still. + /// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein + /// Sync läuft (globaler Guard). + Future syncAlles({bool automatisch = false}) async { + if (_laeuftGlobal) return false; + _laeuftGlobal = true; + laeuftNotifier.value = true; + String? konfliktBatch; + try { + _zeigeSyncNotification('Verbinde…', 0, 0); + onFortschritt?.call('Verbinde...', 0); + onFortschrittZaehler?.call(0, 0); + + // ── Phase 1: Songs ── + onFortschritt?.call('Lade Songs…', 0.1); + final serverSongs = await cloud.listSongs(); + final dir = + Directory('${(await getApplicationDocumentsDirectory()).path}/music'); + if (!await dir.exists()) await dir.create(recursive: true); + + int downloaded = 0; + int totalNew = 0; + + // Zähle neue Songs + for (final song in serverSongs) { + final sid = song['id']?.toString() ?? ''; + if (sid.isEmpty) continue; + final existing = await _db.songNachCloudId(sid); + if (existing == null) totalNew++; + } + + // Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server) + int processed = 0; + for (final song in serverSongs) { + final sid = song['id']?.toString() ?? ''; + if (sid.isEmpty) continue; + processed++; + // Fortschritt je Song (auch für bereits vorhandene) — „12/25“ + _zeigeSyncNotification('Synchronisiere…', processed, serverSongs.length); + onFortschrittZaehler?.call(processed, serverSongs.length); + final title = (song['title'] ?? 'unknown').toString(); + final existing = await _db.songNachCloudId(sid); + if (existing != null) { + final serverTitel = title.trim(); + final lokalerTitel = existing.titel.trim(); + final serverKuenstler = (song['artist']?.toString() ?? '').trim(); + final lokalerKuenstler = existing.kuenstler.trim(); + final titelWeichtAb = serverTitel.isNotEmpty && + lokalerTitel.toLowerCase() != serverTitel.toLowerCase(); + final kuenstlerWeichtAb = serverKuenstler.isNotEmpty && + lokalerKuenstler.toLowerCase() != serverKuenstler.toLowerCase(); + if (titelWeichtAb || kuenstlerWeichtAb) { + // Batch: einmal gewählt → für alle weiteren Konflikte anwenden + String? wahl; + final batch = konfliktBatch; + if (batch != null) { + wahl = batch == 'ueberspringen' ? 'lokal' : batch; + } else if (automatisch) { + // Auto-Sync: keine Dialoge — Server-Metadaten gewinnen still + wahl = 'server'; + } else { + final ergebnis = + onKonflikt != null ? await onKonflikt!(existing, song) : null; + if (ergebnis != null) { + wahl = ergebnis.wahl; + if (ergebnis.fuerAlle && + konfliktBatch == null && + wahl != null) { + konfliktBatch = wahl; + } + } + } + if (wahl == 'server') { + await _db.cloudMetadatenAktualisieren(existing.id!, + title: serverTitel, artist: serverKuenstler); + downloaded++; + } else if (wahl == 'beide') { + // Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen + final safeTitle = sanitizeDateiname(serverTitel); + var dest = '${dir.path}/$safeTitle'; + if (await File(dest).exists()) { + dest = '${dir.path}/$safeTitle (Server)'; + } + if (await cloud.download(sid, dest)) { + await _db.songEinfuegen(Song( + titel: serverTitel, + kuenstler: serverKuenstler, + dauerSekunden: 0, + dateiPfad: dest, + downloadQuelle: 'cloud', + istHeruntergeladen: true, + )); + downloaded++; + } + } + // 'lokal' → nichts tun (lokale Version behalten) + } + continue; + } + + onFortschritt?.call('Download: $title…', + 0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1))); + final safeTitle = sanitizeDateiname(title); + final dest = '${dir.path}/$safeTitle'; + if (await cloud.download(sid, dest)) { + downloaded++; + // In DB eintragen mit cloud_id + // (vereinfacht: ID3-Reader würde Titel extrahieren) + } + await Future.delayed( + const Duration(milliseconds: 50)); // UI-Update erlauben + } + + // ── Phase 2: Favoriten bidirektional (Merge lokal ∪ Server) ── + onFortschritt?.call('Sync Favoriten…', 0.55); + final serverFavs = await cloud.getFavorites(); + final serverIds = serverFavs + .map((f) => f['id']?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toSet(); + final lokalIds = await _favoriten.favoritenCloudIds(); + final merged = favoritenMerge(lokal: lokalIds, server: serverIds); + // Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten + // bleiben erhalten (kein Datenverlust in beide Richtungen) + await cloud.syncFavorites(merged); + // Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song + // lokal existiert); `server_favorites` spiegelt den Merge-Zustand + for (final cid in serverIds) { + await _favoriten.merkeCloudFavorit(cid); + } + await _db.serverFavoritesSet(merged); + onFavoritenAnzahl?.call(merged.length); + onFortschrittZaehler?.call(downloaded + merged.length, serverSongs.length); + + // ── Phase 3: Playlisten ── + onFortschritt?.call('Sync Playlisten…', 0.7); + int playlistCount = 0; + if (onPlaylistenLaden != null) { + try { + playlistCount = await onPlaylistenLaden!(); + } catch (e) { + MeloLogger().fehler('cloud_playlisten_sync', e); + } + } + + // ── Phase 4: Sync-Metadaten + letzter Sync ── + onFortschritt?.call('Speichere Sync-Zeitpunkt...', 0.9); + await cloud.syncAll(); + final now = DateTime.now().toIso8601String(); + await _db.syncMetaSet('last_full_sync', now); + // Nur bei Erfolg persistieren (Auto-Sync-Fälligkeit hängt daran) + final p = await SharedPreferences.getInstance(); + final zeit = _formatZeit(DateTime.now()); + await p.setString('cloud_last_sync', zeit); + await p.setString('cloud_last_sync_ts', now); + + onFortschritt?.call('Fertig!', 1.0); + onStatus?.call( + '$downloaded Songs + ${merged.length} Favoriten synchronisiert', + ok: true); + // UI-Nachladen (Status, Playlisten, Favoriten, letzter Sync) + await onNachSync?.call(); + onHistorie?.call(downloaded, merged.length, playlistCount); + _zeigeSyncFertigNotification(downloaded, merged.length); + MeloLogger().aktion('cloud_sync_all', { + 'downloaded': downloaded, + 'favorites': merged.length, + 'playlists': playlistCount, + }); + return true; + } catch (e) { + MeloLogger().fehler('cloud_sync_all', e); + onStatus?.call('Sync-Fehler: $e', ok: false); + _zeigeSyncFehlerNotification(); + return false; + } finally { + // Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während + // des Syncs verlassen wurde („Im Hintergrund fortsetzen“). + _laeuftGlobal = false; + laeuftNotifier.value = false; + try { + notificationsPlugin.cancel(id: _syncNotifyId); + } catch (_) { + // Plugin kann beim App-Exit bereits disposed sein + } + onSyncEnde?.call(); + } + } + + // ─── Notifications ─── + + /// Persistente Fortschritts-Notification (nicht wegwischbar, ongoing). + void _zeigeSyncNotification(String body, int aktuell, int gesamt) { + if (!Platform.isAndroid) return; + final maxP = gesamt > 0 ? gesamt : 1; + final p = aktuell > maxP ? maxP : aktuell; + notificationsPlugin.show( + id: _syncNotifyId, + title: 'Synchronisiere…', + body: gesamt > 0 ? '$aktuell / $gesamt Songs' : body, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + _syncChannelId, + 'Melo Sync', + channelDescription: 'Cloud-Sync-Fortschritt', + importance: Importance.low, + priority: Priority.low, + onlyAlertOnce: true, + showProgress: true, + maxProgress: maxP, + progress: p, + ongoing: true, + autoCancel: false, + ), + ), + ); + } + + /// Abschluss-Notification — tippbar (öffnet den Cloud-Tab, payload + /// 'sync_fertig' wird in main.dart behandelt). + void _zeigeSyncFertigNotification(int songs, int favoriten) { + if (!Platform.isAndroid) return; + try { + notificationsPlugin.cancel(id: _syncNotifyId); + } catch (_) {} + notificationsPlugin.show( + id: _syncNotifyId + 1, + title: 'Sync fertig', + body: '$songs Songs, $favoriten Favoriten synchronisiert ✅', + payload: 'sync_fertig', + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + _syncChannelId, + 'Melo Sync', + channelDescription: 'Cloud-Sync-Abschluss', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + ), + ), + ); + } + + /// Fehler-Notification (tippbar, öffnet den Cloud-Tab). + void _zeigeSyncFehlerNotification() { + if (!Platform.isAndroid) return; + try { + notificationsPlugin.cancel(id: _syncNotifyId); + } catch (_) {} + notificationsPlugin.show( + id: _syncNotifyId + 1, + title: 'Sync fehlgeschlagen', + body: 'Bitte erneut versuchen ❌', + payload: 'sync_fehler', + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + _syncChannelId, + 'Melo Sync', + channelDescription: 'Cloud-Sync-Abschluss', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + ), + ), + ); + } + + String _formatZeit(DateTime? dt) { + if (dt == null) return 'Nie'; + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } +}