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 'package:flutter_background_service/flutter_background_service.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}); } /// Zusammenfassung der Änderungen eines Sync-Laufs (Konflikt-Report). /// /// Der SyncService sammelt die Zähler + Detail-Zeilen; der CloudScreen zeigt /// sie nach dem Sync als Dialog („Willkommen zurück! …“ mit [Übernehmen] / /// [Details]) an — v.a. wenn der letzte Sync länger als 24h zurückliegt. class SyncBericht { final int neueSongs; // neu heruntergeladene Server-Songs final int geloeschteSongs; // lokal nachgezogene Server-Löschungen (Tombstones) final int favoritenGeaendert; // Merge-Differenz lokal ↔ Server /// Menschlich lesbare Änderungszeilen („+ Neuer Song: …“, „− Gelöscht: …“). final List details; /// Letzter Sync-ZEITPUNKT VOR diesem Lauf (Snapshot vor listSongs, /// MED-1) — Basis für den 24h-Check des „Willkommen zurück!“-Dialogs /// (LOW-1). null = noch nie gesynct (Erst-Sync) → Dialog zeigen. final DateTime? letzterSyncVorher; const SyncBericht({ required this.neueSongs, required this.geloeschteSongs, required this.favoritenGeaendert, this.details = const [], this.letzterSyncVorher, }); bool get hatAenderungen => neueSongs > 0 || geloeschteSongs > 0 || favoritenGeaendert > 0; /// Kurzfassung: „+8 neue Songs · −3 gelöscht · ⭐5 Favoriten geändert“. String get zusammenfassung { final teile = [ if (neueSongs > 0) '+$neueSongs neue Songs', if (geloeschteSongs > 0) '−$geloeschteSongs gelöscht', if (favoritenGeaendert > 0) '⭐$favoritenGeaendert Favoriten geändert', ]; return teile.join(' · '); } /// LOW-1: „Willkommen zurück!“-Dialog nur nach längerer Pause zeigen — /// letzter Sync mehr als 24h zurück (oder noch nie gesynct). [jetzt] /// injizierbar für Tests. bool nachLangerPause({DateTime? jetzt}) { final vorher = letzterSyncVorher; if (vorher == null) return true; final j = jetzt ?? DateTime.now(); return j.difference(vorher) > const Duration(hours: 24); } } /// 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); } /// Tombstone-Entscheidung (Sprint E): Soll eine serverseitige Löschung /// ([deletedAt] = ISO-Zeitstempel) lokal angewendet werden? /// /// Ja, wenn … kein letzter Sync bekannt ist (Erst-Sync), die Server- /// Löschung keinen Zeitstempel hat (Alt-Tombstone) oder die Löschung /// NEUER als der letzte Sync ist. Reine, statische Funktion → testbar. @visibleForTesting static bool tombstoneAnwenden({DateTime? letzterSync, String? deletedAt}) { final d = deletedAt != null ? DateTime.tryParse(deletedAt) : null; return letzterSync == null || d == null || d.isAfter(letzterSync); } /// Baut den lokalen [Song]-DB-Eintrag aus einem Server-Listeneintrag /// (handle_list-Felder: id/title/artist/duration/size/istKorrupt). /// /// Grundlage des MED-2-Fixes: Neue Server-Songs werden nach dem Download /// MIT cloud_id + Titeldaten in die lokale DB eingefügt — vorher fehlten /// sie in der Bibliothek und wurden bei jedem Sync erneut heruntergeladen. /// `ytUrl` wird bewusst NICHT übernommen (v2.52-F2: Der Server ist die /// Quelle für YT-Links, lokal wird yt_url nicht mehr persistiert). /// Reine, statische Funktion → testbar. @visibleForTesting static Song songAusServerMap(Map song, String destPath) { final dauerRaw = song['duration']; final dauer = dauerRaw is num ? dauerRaw.toInt() : int.tryParse(dauerRaw?.toString() ?? '') ?? 0; final groesseRaw = song['size']; final groesse = groesseRaw is num ? groesseRaw.toInt() : int.tryParse(groesseRaw?.toString() ?? '') ?? 0; return Song( titel: (song['title'] ?? 'unknown').toString(), kuenstler: (song['artist']?.toString() ?? '').trim(), dauerSekunden: dauer, groesseBytes: groesse, dateiPfad: destPath, downloadQuelle: 'cloud', istHeruntergeladen: true, istKorrupt: song['istKorrupt'] == true, cloudId: song['id']?.toString(), ); } /// Lokale Löschung eines Cloud-Songs an den Server melden (Tombstone). /// Der Server speichert `deleted=1` + `deleted_at` → andere Geräte löschen /// den Song nach ihrem nächsten Sync ebenfalls lokal (keine Zombie-Songs). /// Aufrufen, sobald ein Song mit cloud_id lokal gelöscht wird. Future meldeLoeschung(String cloudId) async { return cloud.delete(cloudId, deletedAt: DateTime.now().toIso8601String()); } // ─── 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; /// Konflikt-Report: wird nach dem Sync aufgerufen, wenn es Änderungen gab /// (neue/gelöschte Songs, Favoriten-Merge). UI zeigt den Zusammenfassungs- /// Dialog mit [Übernehmen]/[Details]. void Function(SyncBericht bericht)? onBericht; /// 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). /// /// v2.55.2: Startet den Foreground-Service (FlutterBackgroundService), /// damit der Sync auch bei Display-aus und minimierter App weiterläuft. Future syncAlles({bool automatisch = false}) async { if (_laeuftGlobal) return false; _laeuftGlobal = true; laeuftNotifier.value = true; String? konfliktBatch; // v2.55.2: Foreground-Service starten — hält den Prozess im Hintergrund // aktiv, auch wenn das Display aus ist oder die App minimiert wird. await _starteForegroundService(); try { _zeigeSyncNotification('Verbinde…', 0, 0); onFortschritt?.call('Verbinde...', 0); onFortschrittZaehler?.call(0, 0); // ── Phase 1: Songs (inkl. Tombstone-Sync, Sprint E) ── onFortschritt?.call('Lade Songs…', 0.1); // MED-1 (Tombstone-Race): letzterSync-Snapshot VOR listSongs lesen. // Der persistierte „letzter Sync“ darf nie später liegen als die // letzte Beobachtung der Server-Liste — sonst gilt eine Löschung, die // zwischen listSongs und Sync-Ende auf dem Server ankommt, im nächsten // Sync als „bereits gesehen“ (deletedAt < letzterSync) und wird // dauerhaft übersprungen (Zombie-Song). Der Snapshot (Sync-BEGINN, // nicht now-am-Ende) wird am Ende persistiert. final prefs = await SharedPreferences.getInstance(); final letzterSyncRaw = prefs.getString('cloud_last_sync_ts'); final letzterSync = letzterSyncRaw != null ? DateTime.tryParse(letzterSyncRaw) : null; final syncSnapshot = DateTime.now(); final serverSongs = await cloud.listSongs(); final dir = Directory('${(await getApplicationDocumentsDirectory()).path}/music'); if (!await dir.exists()) await dir.create(recursive: true); // ── Tombstones zuerst: serverseitig gelöschte Songs lokal nachziehen ── // (keine Zombie-Songs: Offline-Geräte löschen nach dem Sync ebenfalls) final tombstones = serverSongs.where((s) => s['deleted'] == true).toList(); final aktiveSongs = serverSongs.where((s) => s['deleted'] != true).toList(); var geloeschteSongs = 0; final loeschDetails = []; for (final ts in tombstones) { final sid = ts['id']?.toString() ?? ''; if (sid.isEmpty) continue; if (!tombstoneAnwenden( letzterSync: letzterSync, deletedAt: ts['deletedAt']?.toString())) { continue; } final lokal = await _db.songNachCloudId(sid); if (lokal == null || lokal.id == null) continue; // Datei entfernen (falls vorhanden), dann DB-Eintrag löschen try { final f = File(lokal.dateiPfad); if (lokal.dateiPfad.isNotEmpty && await f.exists()) await f.delete(); } catch (_) { // Datei-Fehler dürfen den Sync nicht abbrechen } await _db.loeschSong(lokal.id!); geloeschteSongs++; loeschDetails.add('− Gelöscht: ${lokal.titel}'); } int downloaded = 0; int neueSongs = 0; final neueDetails = []; int totalNew = 0; // Zähle neue Songs (nur aktive — Tombstones sind oben erledigt) for (final song in aktiveSongs) { 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 aktiveSongs) { final sid = song['id']?.toString() ?? ''; if (sid.isEmpty) continue; processed++; // Fortschritt je Song (auch für bereits vorhandene) — „12/25“ _zeigeSyncNotification('Synchronisiere…', processed, aktiveSongs.length); onFortschrittZaehler?.call(processed, aktiveSongs.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); // Eindeutiger Zielpfad: Kollisionen (gleichnamiger Titel oder // verwaiste Datei aus einem früheren Sync ohne DB-Eintrag) würden // die Datei überschreiben bzw. am UNIQUE-Constraint der // datei_pfad-Spalte scheitern (Insert würde still ignoriert). var dest = '${dir.path}/$safeTitle'; var kollision = 2; while (await File(dest).exists()) { dest = '${dir.path}/$safeTitle ($kollision)'; kollision++; } if (await cloud.download(sid, dest)) { downloaded++; neueSongs++; neueDetails.add('+ Neuer Song: $title'); // MED-2: Song mit cloud_id + Titeldaten aus der Server-Liste in // die lokale DB einfügen — sonst fehlt der Song in der // Bibliothek, songNachCloudId bleibt null und der Song wird bei // JEDEM Sync erneut heruntergeladen (GB/Sync) und fälschlich als // „+neu“ gemeldet. await _db.songEinfuegen(songAusServerMap(song, dest)); } 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); final mergedSet = merged.toSet(); // Merge-Differenz = Anzahl der Favoriten-Änderungen (Konflikt-Report): // lokal neu (wurden zum Server gepusht) + vom Server neu (lokal markiert) final favoritenGeaendert = mergedSet.difference(serverIds).length + serverIds.difference(lokalIds).length; final favoritenDetails = [ for (final cid in mergedSet.difference(serverIds)) '⭐ Favorit lokal → Server: $cid', for (final cid in serverIds.difference(lokalIds)) '⭐ Favorit Server → lokal: $cid', ]; // 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, aktiveSongs.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(); // Zeitstempel = Sync-BEGINN (Snapshot VOR listSongs), NICHT das // Sync-Ende: Der Tombstone-Vergleich anderer Geräte hängt am // „letzten Beobachtungszeitpunkt der Server-Liste“ (MED-1). final now = syncSnapshot.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(syncSnapshot); 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, 'neue': neueSongs, 'geloescht': geloeschteSongs, 'favorites': merged.length, 'playlists': playlistCount, }); // Konflikt-Report (Sprint E): Zusammenfassung nur bei echten Änderungen. // Der CloudScreen zeigt den Dialog („Willkommen zurück! …“). final bericht = SyncBericht( neueSongs: neueSongs, geloeschteSongs: geloeschteSongs, favoritenGeaendert: favoritenGeaendert, details: [...neueDetails, ...loeschDetails, ...favoritenDetails], // LOW-1: 24h-Check des „Willkommen zurück!“-Dialogs gegen den // letzterSync-Stand VOR diesem Sync (MED-1-Snapshot) — nicht gegen // den frisch persistierten, sonst wäre die Pause immer 0. letzterSyncVorher: letzterSync, ); if (bericht.hatAenderungen) { onBericht?.call(bericht); } 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 } // v2.55.2: Foreground-Service stoppen, sobald der Sync beendet ist. await _stoppeForegroundService(); onSyncEnde?.call(); } } // ─── Notifications (v2.55.2: HIGH + public für Sperrbildschirm) ─── /// Erzeugt die [AndroidNotificationDetails] für Sync-Notifications. /// Statisch → testbar ohne Platform-/Plugin-Zugriff. /// [ongoing]=true für Fortschritts-Notification (nicht wegwischbar), /// [ongoing]=false für Abschluss-/Fehler-Notification (automatisch gelöscht). @visibleForTesting static AndroidNotificationDetails syncNotificationDetails({ required bool ongoing, int progress = 0, int maxProgress = 0, }) { return AndroidNotificationDetails( _syncChannelId, 'Melo Sync', channelDescription: ongoing ? 'Cloud-Sync-Fortschritt' : 'Cloud-Sync-Abschluss', importance: Importance.high, priority: Priority.high, visibility: NotificationVisibility.public, onlyAlertOnce: true, showProgress: ongoing, maxProgress: ongoing ? (maxProgress > 0 ? maxProgress : 1) : 0, progress: ongoing ? progress : 0, ongoing: ongoing, autoCancel: !ongoing, ); } /// 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: syncNotificationDetails( ongoing: true, progress: p, maxProgress: maxP, ), ), ); } /// 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: syncNotificationDetails(ongoing: false), ), ); } /// 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: syncNotificationDetails(ongoing: false), ), ); } // ─── Foreground-Service (v2.55.2) ─── /// Startet den Foreground-Service, damit der Sync auch bei Display-aus /// und minimierter App weiterläuft (Android-Prozess-Garantie). Future _starteForegroundService() async { if (!Platform.isAndroid) return; try { final svc = FlutterBackgroundService(); final laeuft = await svc.isRunning(); if (!laeuft) { await svc.startService(); MeloLogger().aktion('foreground_sync_start', {}); } } catch (e) { MeloLogger().fehler('foreground_sync_start', e); } } /// Stoppt den Foreground-Service nach Sync-Ende. Future _stoppeForegroundService() async { if (!Platform.isAndroid) return; try { final svc = FlutterBackgroundService(); final laeuft = await svc.isRunning(); if (laeuft) { svc.invoke('stopService'); // Feuer-und-vergessen (Isolate-Nachricht) MeloLogger().aktion('foreground_sync_stop', {}); } } catch (e) { MeloLogger().fehler('foreground_sync_stop', e); } } String _formatZeit(DateTime? dt) { if (dt == null) return 'Nie'; return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } }