From 2d735464c05cffc3e61395de3379218059edf89f Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 5 Aug 2026 11:14:21 +0200 Subject: [PATCH] =?UTF-8?q?v2.53=20=E2=80=94=20Tombstone-Sync=20+=20Konfli?= =?UTF-8?q?kt-Report=20(Sprint=20E):=20deleted=5Fat,=20lokal=20nachgezogen?= =?UTF-8?q?e=20L=C3=B6schungen,=20Willkommen-zur=C3=BCck-Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/screens/cloud_screen.dart | 104 ++++++++++++++++++++++++ lib/services/cloud_service.dart | 11 ++- lib/services/sync_service.dart | 138 ++++++++++++++++++++++++++++++-- 3 files changed, 244 insertions(+), 9 deletions(-) diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index ef4fcfc..3d89c44 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -118,6 +118,7 @@ class _CloudScreenState extends State _berechneNaechstenSync(); }; _sync.onSyncEnde = _syncEnde; + _sync.onBericht = _zeigeKonfliktReport; // Verbindung stellt der CloudService beim App-Start her (MeloHome.initState). // Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist. _ladeSettings(); @@ -262,6 +263,109 @@ class _CloudScreenState extends State } } + /// Konflikt-Report (Sprint E): Zusammenfassung nach langem Offline-Sync. + /// „Willkommen zurück! Seit letztem Sync: +8 neue · −3 gelöscht · ⭐5“ + void _zeigeKonfliktReport(SyncBericht bericht) { + if (!mounted) return; + final titel = 'Willkommen zurück!'; + final zeilen = [ + 'Seit deinem letzten Sync:', + if (bericht.neueSongs > 0) '➕ ${bericht.neueSongs} neue Songs', + if (bericht.geloeschteSongs > 0) '🗑️ ${bericht.geloeschteSongs} gelöscht', + if (bericht.favoritenGeaendert > 0) + '⭐ ${bericht.favoritenGeaendert} Favoriten geändert', + ]; + showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: Text(titel, + style: const TextStyle(color: Colors.white, fontSize: 18)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: zeilen + .map((z) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text(z, + style: const TextStyle( + color: MeloTheme.textSekundaer, + fontSize: 14)), + )) + .toList(), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(ctx); + if (bericht.details.isNotEmpty) { + _zeigeReportDetails(bericht); + } + }, + child: const Text('Details', + style: TextStyle(color: MeloTheme.rot)), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK', + style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + void _zeigeReportDetails(SyncBericht bericht) { + if (!mounted) return; + showModalBottomSheet( + context: context, + backgroundColor: MeloTheme.dunkel1, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Änderungen', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Flexible( + child: ListView( + shrinkWrap: true, + children: bericht.details + .map((d) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text(d, + style: const TextStyle( + color: MeloTheme.textSekundaer, + fontSize: 13)), + )) + .toList(), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Schließen', + style: TextStyle(color: MeloTheme.rot)), + ), + ), + ], + ), + ), + ), + ); + } + // ─── 📜 Sync-Historie (fürs Dashboard) ─── static const _historieKey = 'cloud_sync_history'; diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index 54683e7..fc2606f 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -149,12 +149,19 @@ class CloudService extends ChangeNotifier { } } - Future delete(String songId) async { + /// Song auf dem Server als gelöscht markieren (Tombstone). + /// [deletedAt] (ISO-Zeitstempel) dokumentiert den Löschzeitpunkt — Offline- + /// Geräte vergleichen ihn mit ihrem letzten Sync und löschen lokal nach + /// (keine Zombie-Songs). Ohne [deletedAt] stempelt der Server selbst. + Future delete(String songId, {String? deletedAt}) async { try { final r = await _client .post(Uri.parse('$_base/api/v1/cloud/delete'), headers: _jsonHeader, - body: jsonEncode({'song_id': songId})) + body: jsonEncode({ + 'song_id': songId, + if (deletedAt != null) 'deleted_at': deletedAt, + })) .timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (_) { diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 5a6381a..15bda44 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -22,6 +22,40 @@ class KonfliktErgebnis { 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; + + 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 = [ + 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) @@ -107,6 +141,26 @@ class SyncService { 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); + } + + /// 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. @@ -134,6 +188,11 @@ class SyncService { /// 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; @@ -156,18 +215,57 @@ class SyncService { onFortschritt?.call('Verbinde...', 0); onFortschrittZaehler?.call(0, 0); - // ── Phase 1: Songs ── + // ── Phase 1: Songs (inkl. Tombstone-Sync, Sprint E) ── 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); + // Letzter Sync-Zeitpunkt: Tombstones werden nur angewendet, wenn die + // Server-Löschung NEUER als der letzte Sync ist. + final prefs = await SharedPreferences.getInstance(); + final letzterSyncRaw = prefs.getString('cloud_last_sync_ts'); + final letzterSync = + letzterSyncRaw != null ? DateTime.tryParse(letzterSyncRaw) : null; + + // ── 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 - for (final song in serverSongs) { + // 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); @@ -176,13 +274,13 @@ class SyncService { // Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server) int processed = 0; - for (final song in serverSongs) { + 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, serverSongs.length); - onFortschrittZaehler?.call(processed, serverSongs.length); + _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) { @@ -249,6 +347,8 @@ class SyncService { final dest = '${dir.path}/$safeTitle'; if (await cloud.download(sid, dest)) { downloaded++; + neueSongs++; + neueDetails.add('+ Neuer Song: $title'); // In DB eintragen mit cloud_id // (vereinfacht: ID3-Reader würde Titel extrahieren) } @@ -265,6 +365,17 @@ class SyncService { .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); @@ -275,7 +386,7 @@ class SyncService { } await _db.serverFavoritesSet(merged); onFavoritenAnzahl?.call(merged.length); - onFortschrittZaehler?.call(downloaded + merged.length, serverSongs.length); + onFortschrittZaehler?.call(downloaded + merged.length, aktiveSongs.length); // ── Phase 3: Playlisten ── onFortschritt?.call('Sync Playlisten…', 0.7); @@ -309,9 +420,22 @@ class SyncService { _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);