From 9c854c5ac0acb5ecf2b0b93dcb55fcd644efa7b6 Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 5 Aug 2026 08:12:45 +0200 Subject: [PATCH] =?UTF-8?q?v2.51.4=20=E2=80=94=20Review-Fix=20MED-3=20+=20?= =?UTF-8?q?MED-4:=20Konflikt-Dialog=20(Batch/Auto)=20+=20echter=20Sync-Gua?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- lib/screens/cloud_screen.dart | 273 ++++++++++++++++++++++------------ 1 file changed, 179 insertions(+), 94 deletions(-) diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index 43af903..c8fae14 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -49,9 +49,20 @@ class _CloudScreenState extends State // ─── Sync-Fortschritt ─── String _syncPhase = ''; double _syncFortschritt = 0; + /// UI-Flag: Fortschritts-Ansicht anzeigen („Im Hintergrund fortsetzen“ + /// setzt NUR dieses Flag zurück). 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; + // ─── 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) ─── List> _syncHistorie = []; @@ -179,8 +190,9 @@ class _CloudScreenState extends State } Future _autoSyncDurchfuehren() async { - if (_syncLaeuft) return; - await _syncAlles(); + if (_syncProzessLaeuft) return; + final erfolgreich = await _syncAlles(automatisch: true); + if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben final p = await SharedPreferences.getInstance(); final now = DateTime.now(); final zeit = _formatZeit(now); @@ -196,14 +208,22 @@ class _CloudScreenState extends State // ─── 🔄 Komplett-Sync ─── - Future _syncAlles() async { - if (_syncLaeuft) return; - setState(() { - _syncLaeuft = true; - _syncPhase = 'Verbinde...'; - _syncFortschritt = 0; - _syncedItems = 0; - }); + /// 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 (_syncProzessLaeuft-Guard). + Future _syncAlles({bool automatisch = false}) async { + if (_syncProzessLaeuft) return false; + _syncProzessLaeuft = true; + _konfliktBatch = null; + if (mounted) { + setState(() { + _syncLaeuft = true; + _syncPhase = 'Verbinde...'; + _syncFortschritt = 0; + _syncedItems = 0; + }); + } _syncAnimController.repeat(); try { @@ -234,19 +254,35 @@ class _CloudScreenState extends State final title = (song['title'] ?? 'unknown').toString(); final existing = await db.songNachCloudId(sid); 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 lokalerTitel = existing.titel.trim(); - if (serverTitel.isNotEmpty && - lokalerTitel.toLowerCase() != serverTitel.toLowerCase()) { - final wahl = await _konfliktDialog(existing, song); + 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 { + wahl = await _konfliktDialog(existing, song); + } if (wahl == 'server') { await db.cloudMetadatenAktualisieren( existing.id!, title: serverTitel, - artist: (song['artist']?.toString() ?? '').trim(), + artist: serverKuenstler, ); _syncedItems++; + downloaded++; } else if (wahl == 'beide') { // Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen final safeTitle = sanitizeDateiname(serverTitel); @@ -264,7 +300,7 @@ class _CloudScreenState extends State istHeruntergeladen: true, )); downloaded++; - _syncedItems = downloaded; + _syncedItems++; } } // 'lokal' → nichts tun (lokale Version behalten) @@ -283,7 +319,7 @@ class _CloudScreenState extends State // (vereinfacht: ID3-Reader würde Titel extrahieren) } processed++; - _syncedItems = downloaded; + _syncedItems++; await Future.delayed( const Duration(milliseconds: 50)); // UI-Update erlauben } @@ -331,12 +367,21 @@ class _CloudScreenState extends State 'favorites': favIds.length, 'playlists': _serverPlaylists.length, }); + return true; } catch (e) { MeloLogger().fehler('cloud_sync_all', e); _setzeStatus('Sync-Fehler: $e', ok: false); + return false; } finally { - _syncAnimController.stop(); - _syncAnimController.value = 0; + // 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.value = 0; + } catch (_) { + // Controller kann bereits disposed sein (Screen verlassen) + } if (mounted) { setState(() { _syncLaeuft = false; @@ -420,90 +465,123 @@ class _CloudScreenState extends State return 'Heute: ${teile.join(', ')}'; } - /// Konflikt-Dialog: Titel lokal ≠ Server. NUR wenn ein Konflikt existiert. - /// Rückgabe: 'lokal' | 'server' | 'beide' | null (abgebrochen) + /// Konflikt-Dialog: Titel ODER Künstler weichen lokal ≠ Server ab. + /// 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 _konfliktDialog(Song lokal, Map serverSong) async { final serverTitel = (serverSong['title'] ?? '?').toString(); final serverKuenstler = (serverSong['artist'] ?? '?').toString(); if (!mounted) return null; + var fuerAlle = false; return showDialog( context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - backgroundColor: MeloTheme.dunkel1, - title: const Text('⚠️ Konflikt', - style: TextStyle(color: Colors.white, fontSize: 17)), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Titel weichen voneinander ab:', - style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12), + barrierDismissible: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('⚠️ Konflikt', + style: TextStyle(color: Colors.white, fontSize: 17)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Titel oder Künstler weichen voneinander ab:', + style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('📱 Lokal', + style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), + const SizedBox(height: 2), + Text(lokal.titel, + style: const TextStyle(color: Colors.white, fontSize: 13)), + Text(lokal.kuenstler, + style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), + ], + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('☁️ Server', + style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), + const SizedBox(height: 2), + Text(serverTitel, + style: const TextStyle(color: Colors.white, fontSize: 13)), + Text(serverKuenstler, + style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), + ], + ), + ), + 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: [ + TextButton( + onPressed: () => Navigator.pop(ctx, 'lokal'), + child: const Text('Lokale behalten', + style: TextStyle(color: MeloTheme.textSekundaer)), ), - const SizedBox(height: 10), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: MeloTheme.dunkel2, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('📱 Lokal', - style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), - const SizedBox(height: 2), - Text(lokal.titel, - style: const TextStyle(color: Colors.white, fontSize: 13)), - Text(lokal.kuenstler, - style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), - ], - ), + TextButton( + onPressed: () => Navigator.pop(ctx, 'beide'), + child: const Text('Beide behalten', + style: TextStyle(color: MeloTheme.textSekundaer)), ), - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: MeloTheme.dunkel2, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('☁️ Server', - style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), - const SizedBox(height: 2), - Text(serverTitel, - style: const TextStyle(color: Colors.white, fontSize: 13)), - Text(serverKuenstler, - style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)), - ], - ), + TextButton( + onPressed: () => Navigator.pop(ctx, 'server'), + child: const Text('Server übernehmen', + style: TextStyle(color: MeloTheme.rot)), + ), + TextButton( + onPressed: () { + _konfliktBatch = 'ueberspringen'; + Navigator.pop(ctx, 'ueberspringen'); + }, + child: const Text('Alle weiteren überspringen', + style: TextStyle(color: MeloTheme.textSekundaer)), ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, 'lokal'), - child: const Text('Lokale behalten', - style: TextStyle(color: MeloTheme.textSekundaer)), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, 'beide'), - child: const Text('Beide behalten', - style: TextStyle(color: MeloTheme.textSekundaer)), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, 'server'), - child: const Text('Server übernehmen', - style: TextStyle(color: MeloTheme.rot)), - ), - ], ), - ); + ).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) { @@ -721,8 +799,12 @@ class _CloudScreenState extends State } Future _download() async { - if (_syncLaeuft) return; - await _syncAlles(); + if (_syncProzessLaeuft) { + _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 now = DateTime.now(); final zeit = _formatZeit(now); @@ -971,7 +1053,10 @@ class _CloudScreenState extends State const SizedBox(height: 24), TextButton( 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); }, child: const Text('Im Hintergrund fortsetzen',