v2.53 — Tombstone-Sync + Konflikt-Report (Sprint E): deleted_at, lokal nachgezogene Löschungen, Willkommen-zurück-Dialog
This commit is contained in:
@@ -118,6 +118,7 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
_berechneNaechstenSync();
|
_berechneNaechstenSync();
|
||||||
};
|
};
|
||||||
_sync.onSyncEnde = _syncEnde;
|
_sync.onSyncEnde = _syncEnde;
|
||||||
|
_sync.onBericht = _zeigeKonfliktReport;
|
||||||
// Verbindung stellt der CloudService beim App-Start her (MeloHome.initState).
|
// Verbindung stellt der CloudService beim App-Start her (MeloHome.initState).
|
||||||
// Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist.
|
// Hier nur Settings laden + Serverdaten, sobald der Service verbunden ist.
|
||||||
_ladeSettings();
|
_ladeSettings();
|
||||||
@@ -262,6 +263,109 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 = <String>[
|
||||||
|
'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<void>(
|
||||||
|
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<void>(
|
||||||
|
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) ───
|
// ─── 📜 Sync-Historie (fürs Dashboard) ───
|
||||||
|
|
||||||
static const _historieKey = 'cloud_sync_history';
|
static const _historieKey = 'cloud_sync_history';
|
||||||
|
|||||||
@@ -149,12 +149,19 @@ class CloudService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> 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<bool> delete(String songId, {String? deletedAt}) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.post(Uri.parse('$_base/api/v1/cloud/delete'),
|
.post(Uri.parse('$_base/api/v1/cloud/delete'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'song_id': songId}))
|
body: jsonEncode({
|
||||||
|
'song_id': songId,
|
||||||
|
if (deletedAt != null) 'deleted_at': deletedAt,
|
||||||
|
}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
return r.statusCode == 200;
|
return r.statusCode == 200;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -22,6 +22,40 @@ class KonfliktErgebnis {
|
|||||||
const KonfliktErgebnis(this.wahl, {this.fuerAlle = false});
|
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).
|
/// Zentraler Cloud-Sync (Sprint D, v2.52.2).
|
||||||
///
|
///
|
||||||
/// Der komplette Sync-Loop (Songs → Favoriten-Merge → Playlisten → Metadaten)
|
/// Der komplette Sync-Loop (Songs → Favoriten-Merge → Playlisten → Metadaten)
|
||||||
@@ -107,6 +141,26 @@ class SyncService {
|
|||||||
await syncAlles(automatisch: true);
|
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<bool> meldeLoeschung(String cloudId) async {
|
||||||
|
return cloud.delete(cloudId, deletedAt: DateTime.now().toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── UI-Callbacks (CloudScreen verdrahtet sie; ohne UI = No-op) ───
|
// ─── UI-Callbacks (CloudScreen verdrahtet sie; ohne UI = No-op) ───
|
||||||
|
|
||||||
/// Phase + Fortschritt (0.0–1.0) für die Fortschritts-Ansicht im Cloud-Tab.
|
/// 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.
|
/// Nach erfolgreichem Sync: Status/Playlisten/Favoriten/letzten Sync neu laden.
|
||||||
Future<void> Function()? onNachSync;
|
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.
|
/// Immer am Ende (auch bei Fehler): Animation stoppen, Sync-Ansicht schließen.
|
||||||
void Function()? onSyncEnde;
|
void Function()? onSyncEnde;
|
||||||
|
|
||||||
@@ -156,18 +215,57 @@ class SyncService {
|
|||||||
onFortschritt?.call('Verbinde...', 0);
|
onFortschritt?.call('Verbinde...', 0);
|
||||||
onFortschrittZaehler?.call(0, 0);
|
onFortschrittZaehler?.call(0, 0);
|
||||||
|
|
||||||
// ── Phase 1: Songs ──
|
// ── Phase 1: Songs (inkl. Tombstone-Sync, Sprint E) ──
|
||||||
onFortschritt?.call('Lade Songs…', 0.1);
|
onFortschritt?.call('Lade Songs…', 0.1);
|
||||||
final serverSongs = await cloud.listSongs();
|
final serverSongs = await cloud.listSongs();
|
||||||
final dir =
|
final dir =
|
||||||
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
|
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
|
||||||
if (!await dir.exists()) await dir.create(recursive: true);
|
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 = <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 downloaded = 0;
|
||||||
|
int neueSongs = 0;
|
||||||
|
final neueDetails = <String>[];
|
||||||
int totalNew = 0;
|
int totalNew = 0;
|
||||||
|
|
||||||
// Zähle neue Songs
|
// Zähle neue Songs (nur aktive — Tombstones sind oben erledigt)
|
||||||
for (final song in serverSongs) {
|
for (final song in aktiveSongs) {
|
||||||
final sid = song['id']?.toString() ?? '';
|
final sid = song['id']?.toString() ?? '';
|
||||||
if (sid.isEmpty) continue;
|
if (sid.isEmpty) continue;
|
||||||
final existing = await _db.songNachCloudId(sid);
|
final existing = await _db.songNachCloudId(sid);
|
||||||
@@ -176,13 +274,13 @@ class SyncService {
|
|||||||
|
|
||||||
// Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server)
|
// Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server)
|
||||||
int processed = 0;
|
int processed = 0;
|
||||||
for (final song in serverSongs) {
|
for (final song in aktiveSongs) {
|
||||||
final sid = song['id']?.toString() ?? '';
|
final sid = song['id']?.toString() ?? '';
|
||||||
if (sid.isEmpty) continue;
|
if (sid.isEmpty) continue;
|
||||||
processed++;
|
processed++;
|
||||||
// Fortschritt je Song (auch für bereits vorhandene) — „12/25“
|
// Fortschritt je Song (auch für bereits vorhandene) — „12/25“
|
||||||
_zeigeSyncNotification('Synchronisiere…', processed, serverSongs.length);
|
_zeigeSyncNotification('Synchronisiere…', processed, aktiveSongs.length);
|
||||||
onFortschrittZaehler?.call(processed, serverSongs.length);
|
onFortschrittZaehler?.call(processed, aktiveSongs.length);
|
||||||
final title = (song['title'] ?? 'unknown').toString();
|
final title = (song['title'] ?? 'unknown').toString();
|
||||||
final existing = await _db.songNachCloudId(sid);
|
final existing = await _db.songNachCloudId(sid);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
@@ -249,6 +347,8 @@ class SyncService {
|
|||||||
final dest = '${dir.path}/$safeTitle';
|
final dest = '${dir.path}/$safeTitle';
|
||||||
if (await cloud.download(sid, dest)) {
|
if (await cloud.download(sid, dest)) {
|
||||||
downloaded++;
|
downloaded++;
|
||||||
|
neueSongs++;
|
||||||
|
neueDetails.add('+ Neuer Song: $title');
|
||||||
// In DB eintragen mit cloud_id
|
// In DB eintragen mit cloud_id
|
||||||
// (vereinfacht: ID3-Reader würde Titel extrahieren)
|
// (vereinfacht: ID3-Reader würde Titel extrahieren)
|
||||||
}
|
}
|
||||||
@@ -265,6 +365,17 @@ class SyncService {
|
|||||||
.toSet();
|
.toSet();
|
||||||
final lokalIds = await _favoriten.favoritenCloudIds();
|
final lokalIds = await _favoriten.favoritenCloudIds();
|
||||||
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
|
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
|
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
|
||||||
// bleiben erhalten (kein Datenverlust in beide Richtungen)
|
// bleiben erhalten (kein Datenverlust in beide Richtungen)
|
||||||
await cloud.syncFavorites(merged);
|
await cloud.syncFavorites(merged);
|
||||||
@@ -275,7 +386,7 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
await _db.serverFavoritesSet(merged);
|
await _db.serverFavoritesSet(merged);
|
||||||
onFavoritenAnzahl?.call(merged.length);
|
onFavoritenAnzahl?.call(merged.length);
|
||||||
onFortschrittZaehler?.call(downloaded + merged.length, serverSongs.length);
|
onFortschrittZaehler?.call(downloaded + merged.length, aktiveSongs.length);
|
||||||
|
|
||||||
// ── Phase 3: Playlisten ──
|
// ── Phase 3: Playlisten ──
|
||||||
onFortschritt?.call('Sync Playlisten…', 0.7);
|
onFortschritt?.call('Sync Playlisten…', 0.7);
|
||||||
@@ -309,9 +420,22 @@ class SyncService {
|
|||||||
_zeigeSyncFertigNotification(downloaded, merged.length);
|
_zeigeSyncFertigNotification(downloaded, merged.length);
|
||||||
MeloLogger().aktion('cloud_sync_all', {
|
MeloLogger().aktion('cloud_sync_all', {
|
||||||
'downloaded': downloaded,
|
'downloaded': downloaded,
|
||||||
|
'neue': neueSongs,
|
||||||
|
'geloescht': geloeschteSongs,
|
||||||
'favorites': merged.length,
|
'favorites': merged.length,
|
||||||
'playlists': playlistCount,
|
'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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
MeloLogger().fehler('cloud_sync_all', e);
|
MeloLogger().fehler('cloud_sync_all', e);
|
||||||
|
|||||||
Reference in New Issue
Block a user