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); /// Wird inkrementiert, wenn die Sync-Abschluss-/Fehler-Notification getippt /// wird — MeloHome hört darauf und öffnet den Cloud-Tab (main.dart /// verdrahtet den Tipp via `onDidReceiveNotificationResponse`). static final ValueNotifier syncBenachrichtigungGetippt = ValueNotifier(0); // ─── 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')}'; } }