## MED-2 (Review-Fix, KRITISCH) - sync_service.dart: Nach erfolgreichem cloud.download wird der Song jetzt MIT cloud_id + Titeldaten (Titel/Künstler/Dauer/Größe/istKorrupt aus der Server-Liste) in die lokale DB eingefügt — vorher fehlte der Song in der Bibliothek, songNachCloudId blieb null und jeder Sync lud alle 325 Songs erneut (≈ GB/Sync) und meldete sie fälschlich als „+neu“ - Neuer statischer Helfer `songAusServerMap` (testbar): baut den Song aus den handle_list-Feldern; ytUrl wird bewusst NICHT übernommen (v2.52-F2: Server bleibt YT-Link-Quelle) - Zielpfad-Kollisionen abgesichert: existiert die Datei bereits (verwaister Download aus früherem Sync ohne DB-Eintrag), wird „ (2)“, „ (3)“ … an- gehängt — sonst scheiterte der Insert am UNIQUE-Constraint datei_pfad
597 lines
24 KiB
Dart
597 lines
24 KiB
Dart
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});
|
||
}
|
||
|
||
/// 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<String> details;
|
||
|
||
const SyncBericht({
|
||
required this.neueSongs,
|
||
required this.geloeschteSongs,
|
||
required this.favoritenGeaendert,
|
||
this.details = const [],
|
||
});
|
||
|
||
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 = <String>[
|
||
if (neueSongs > 0) '+$neueSongs neue Songs',
|
||
if (geloeschteSongs > 0) '−$geloeschteSongs gelöscht',
|
||
if (favoritenGeaendert > 0) '⭐$favoritenGeaendert Favoriten geändert',
|
||
];
|
||
return teile.join(' · ');
|
||
}
|
||
}
|
||
|
||
/// 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<bool> laeuftNotifier = ValueNotifier<bool>(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<int> syncBenachrichtigungGetippt =
|
||
ValueNotifier<int>(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<void> 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<void> _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<bool> 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<KonfliktErgebnis?> 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<int> Function()? onPlaylistenLaden;
|
||
|
||
/// Nach erfolgreichem Sync: Status/Playlisten/Favoriten/letzten Sync neu laden.
|
||
Future<void> 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).
|
||
Future<bool> 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 (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 = <String>[];
|
||
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 = <String>[];
|
||
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 = <String>[
|
||
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],
|
||
);
|
||
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
|
||
}
|
||
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')}';
|
||
}
|
||
}
|