v2.51.4 — Review-Fix MED-3 + MED-4: Konflikt-Dialog (Batch/Auto) + echter Sync-Guard

## MED-3 (cloud_screen.dart)
- Konflikterkennung prüft jetzt Titel ODER Künstler (case-insensitiv)
- Dialog dismissable (Tap außerhalb = Abbruch → lokale Version behalten,
  Sync läuft weiter — kein Hängenbleiben)
- Batch-Optionen: Checkbox „Für alle weiteren Konflikte übernehmen“
  (_konfliktBatch) + Button „Alle weiteren überspringen“
- Auto-Sync (_autoSyncDurchfuehren) löst Konflikte NICHT interaktiv:
  Server-Metadaten gewinnen still (automatisch: true)

## MED-4 (cloud_screen.dart)
- Neuer echter Sync-Guard _syncProzessLaeuft, getrennt vom UI-Flag _syncLaeuft
- „Im Hintergrund fortsetzen“ setzt nur noch _syncLaeuft zurück — der Guard
  bleibt bis zum echten Loop-Ende (finally) gesperrt → kein Doppel-Sync
- _download/_autoSyncDurchfuehren prüfen _syncProzessLaeuft; „Jetzt Syncen“
  während Hintergrund-Sync zeigt Hinweis
- finally gibt den Guard immer frei + AnimationController-stop try/catch
  (Screen kann während Hintergrund-Sync disposed sein)

## LOW (cloud_screen.dart)
- cloud_last_sync/_ts werden nur noch bei erfolgreichem Sync geschrieben
  (_syncAlles returned bool; Fehler/Guard → kein Schreiben)
- _syncedItems-Zähler konsistent (Server-Übernahmen/Downloads je ++ statt
  Überschreiben)
This commit is contained in:
Dustin
2026-08-05 08:12:45 +02:00
parent a24875726f
commit 9c854c5ac0
+105 -20
View File
@@ -49,9 +49,20 @@ class _CloudScreenState extends State<CloudScreen>
// ─── Sync-Fortschritt ─── // ─── Sync-Fortschritt ───
String _syncPhase = ''; String _syncPhase = '';
double _syncFortschritt = 0; double _syncFortschritt = 0;
/// UI-Flag: Fortschritts-Ansicht anzeigen („Im Hintergrund fortsetzen“
/// setzt NUR dieses Flag zurück).
bool _syncLaeuft = false; bool _syncLaeuft = false;
/// Echter Sync-Guard: bleibt true, solange der Sync-Loop tatsächlich läuft
/// (auch im Hintergrund nach „Im Hintergrund fortsetzen“). Erst nach dem
/// echten Loop-Ende in `finally` wird er freigegeben → kein Doppel-Sync.
bool _syncProzessLaeuft = false;
int _syncedItems = 0; int _syncedItems = 0;
// ─── Konflikt-Batch (MED-3) ───
/// Batch-Entscheidung für alle weiteren Konflikte dieses Sync-Laufs:
/// 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (jeden fragen)
String? _konfliktBatch;
// ─── Sync-Historie (heute) ─── // ─── Sync-Historie (heute) ───
List<Map<String, dynamic>> _syncHistorie = []; List<Map<String, dynamic>> _syncHistorie = [];
@@ -179,8 +190,9 @@ class _CloudScreenState extends State<CloudScreen>
} }
Future<void> _autoSyncDurchfuehren() async { Future<void> _autoSyncDurchfuehren() async {
if (_syncLaeuft) return; if (_syncProzessLaeuft) return;
await _syncAlles(); final erfolgreich = await _syncAlles(automatisch: true);
if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
final now = DateTime.now(); final now = DateTime.now();
final zeit = _formatZeit(now); final zeit = _formatZeit(now);
@@ -196,14 +208,22 @@ class _CloudScreenState extends State<CloudScreen>
// ─── 🔄 Komplett-Sync ─── // ─── 🔄 Komplett-Sync ───
Future<void> _syncAlles() async { /// Komplett-Sync. [automatisch]=true (Auto-Sync-Timer): Konflikte werden
if (_syncLaeuft) return; /// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still.
/// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein
/// Sync läuft (_syncProzessLaeuft-Guard).
Future<bool> _syncAlles({bool automatisch = false}) async {
if (_syncProzessLaeuft) return false;
_syncProzessLaeuft = true;
_konfliktBatch = null;
if (mounted) {
setState(() { setState(() {
_syncLaeuft = true; _syncLaeuft = true;
_syncPhase = 'Verbinde...'; _syncPhase = 'Verbinde...';
_syncFortschritt = 0; _syncFortschritt = 0;
_syncedItems = 0; _syncedItems = 0;
}); });
}
_syncAnimController.repeat(); _syncAnimController.repeat();
try { try {
@@ -234,19 +254,35 @@ class _CloudScreenState extends State<CloudScreen>
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) {
// Konfliktprüfung: Server-Titel weicht vom lokalen ab // Konfliktprüfung: Server-Titel ODER Server-Künstler weichen ab
final serverTitel = title.trim(); final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim(); final lokalerTitel = existing.titel.trim();
if (serverTitel.isNotEmpty && final serverKuenstler = (song['artist']?.toString() ?? '').trim();
lokalerTitel.toLowerCase() != serverTitel.toLowerCase()) { final lokalerKuenstler = existing.kuenstler.trim();
final wahl = await _konfliktDialog(existing, song); 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 {
wahl = await _konfliktDialog(existing, song);
}
if (wahl == 'server') { if (wahl == 'server') {
await db.cloudMetadatenAktualisieren( await db.cloudMetadatenAktualisieren(
existing.id!, existing.id!,
title: serverTitel, title: serverTitel,
artist: (song['artist']?.toString() ?? '').trim(), artist: serverKuenstler,
); );
_syncedItems++; _syncedItems++;
downloaded++;
} else if (wahl == 'beide') { } else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen // Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel); final safeTitle = sanitizeDateiname(serverTitel);
@@ -264,7 +300,7 @@ class _CloudScreenState extends State<CloudScreen>
istHeruntergeladen: true, istHeruntergeladen: true,
)); ));
downloaded++; downloaded++;
_syncedItems = downloaded; _syncedItems++;
} }
} }
// 'lokal' → nichts tun (lokale Version behalten) // 'lokal' → nichts tun (lokale Version behalten)
@@ -283,7 +319,7 @@ class _CloudScreenState extends State<CloudScreen>
// (vereinfacht: ID3-Reader würde Titel extrahieren) // (vereinfacht: ID3-Reader würde Titel extrahieren)
} }
processed++; processed++;
_syncedItems = downloaded; _syncedItems++;
await Future.delayed( await Future.delayed(
const Duration(milliseconds: 50)); // UI-Update erlauben const Duration(milliseconds: 50)); // UI-Update erlauben
} }
@@ -331,12 +367,21 @@ class _CloudScreenState extends State<CloudScreen>
'favorites': favIds.length, 'favorites': favIds.length,
'playlists': _serverPlaylists.length, 'playlists': _serverPlaylists.length,
}); });
return true;
} catch (e) { } catch (e) {
MeloLogger().fehler('cloud_sync_all', e); MeloLogger().fehler('cloud_sync_all', e);
_setzeStatus('Sync-Fehler: $e', ok: false); _setzeStatus('Sync-Fehler: $e', ok: false);
return false;
} finally { } finally {
// Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während
// des Syncs verlassen wurde („Im Hintergrund fortsetzen“).
_syncProzessLaeuft = false;
try {
_syncAnimController.stop(); _syncAnimController.stop();
_syncAnimController.value = 0; _syncAnimController.value = 0;
} catch (_) {
// Controller kann bereits disposed sein (Screen verlassen)
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_syncLaeuft = false; _syncLaeuft = false;
@@ -420,16 +465,21 @@ class _CloudScreenState extends State<CloudScreen>
return 'Heute: ${teile.join(', ')}'; return 'Heute: ${teile.join(', ')}';
} }
/// Konflikt-Dialog: Titel lokal ≠ Server. NUR wenn ein Konflikt existiert. /// Konflikt-Dialog: Titel ODER Künstler weichen lokal ≠ Server ab.
/// Rückgabe: 'lokal' | 'server' | 'beide' | null (abgebrochen) /// Dismissable (Tap außerhalb = Abbruch → lokale Version behalten, der
/// Sync läuft weiter). Batch-Optionen: Checkbox „Für alle übernehmen“ +
/// Button „Alle weiteren überspringen“.
/// Rückgabe: 'lokal' | 'server' | 'beide' | 'ueberspringen' | null
Future<String?> _konfliktDialog(Song lokal, Map serverSong) async { Future<String?> _konfliktDialog(Song lokal, Map serverSong) async {
final serverTitel = (serverSong['title'] ?? '?').toString(); final serverTitel = (serverSong['title'] ?? '?').toString();
final serverKuenstler = (serverSong['artist'] ?? '?').toString(); final serverKuenstler = (serverSong['artist'] ?? '?').toString();
if (!mounted) return null; if (!mounted) return null;
var fuerAlle = false;
return showDialog<String>( return showDialog<String>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: true,
builder: (ctx) => AlertDialog( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: MeloTheme.dunkel1, backgroundColor: MeloTheme.dunkel1,
title: const Text('⚠️ Konflikt', title: const Text('⚠️ Konflikt',
style: TextStyle(color: Colors.white, fontSize: 17)), style: TextStyle(color: Colors.white, fontSize: 17)),
@@ -438,7 +488,7 @@ class _CloudScreenState extends State<CloudScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'Titel weichen voneinander ab:', 'Titel oder Künstler weichen voneinander ab:',
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12), style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -483,6 +533,18 @@ class _CloudScreenState extends State<CloudScreen>
], ],
), ),
), ),
const SizedBox(height: 4),
CheckboxListTile(
value: fuerAlle,
onChanged: (v) => setDialogState(() => fuerAlle = v ?? false),
title: const Text('Für alle weiteren Konflikte übernehmen',
style: TextStyle(color: Colors.white, fontSize: 12)),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: MeloTheme.rot,
checkColor: Colors.white,
),
], ],
), ),
actions: [ actions: [
@@ -501,9 +563,25 @@ class _CloudScreenState extends State<CloudScreen>
child: const Text('Server übernehmen', child: const Text('Server übernehmen',
style: TextStyle(color: MeloTheme.rot)), style: TextStyle(color: MeloTheme.rot)),
), ),
TextButton(
onPressed: () {
_konfliktBatch = 'ueberspringen';
Navigator.pop(ctx, 'ueberspringen');
},
child: const Text('Alle weiteren überspringen',
style: TextStyle(color: MeloTheme.textSekundaer)),
),
], ],
), ),
); ),
).then((wahl) {
// Batch merken: gewählte Entscheidung auf alle restlichen Konflikte
// dieses Sync-Laufs anwenden.
if (wahl != null && fuerAlle && _konfliktBatch == null) {
_konfliktBatch = wahl;
}
return wahl;
});
} }
void _updateSync(String phase, double progress) { void _updateSync(String phase, double progress) {
@@ -721,8 +799,12 @@ class _CloudScreenState extends State<CloudScreen>
} }
Future<void> _download() async { Future<void> _download() async {
if (_syncLaeuft) return; if (_syncProzessLaeuft) {
await _syncAlles(); _setzeStatus('Sync läuft bereits im Hintergrund', ok: false);
return;
}
final erfolgreich = await _syncAlles();
if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
final now = DateTime.now(); final now = DateTime.now();
final zeit = _formatZeit(now); final zeit = _formatZeit(now);
@@ -971,7 +1053,10 @@ class _CloudScreenState extends State<CloudScreen>
const SizedBox(height: 24), const SizedBox(height: 24),
TextButton( TextButton(
onPressed: () { onPressed: () {
// Abbruch — läuft im Hintergrund weiter, UI refreshed // Nur die UI-Persistenz ändern: Fortschritts-Ansicht schließen.
// Der Sync-Loop läuft weiter und der echte Guard
// (_syncProzessLaeuft) bleibt gesperrt, bis der Loop fertig
// ist — ein erneuter „Jetzt Syncen“ startet KEINEN Parallel-Sync.
setState(() => _syncLaeuft = false); setState(() => _syncLaeuft = false);
}, },
child: const Text('Im Hintergrund fortsetzen', child: const Text('Im Hintergrund fortsetzen',