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:
+105
-20
@@ -49,9 +49,20 @@ class _CloudScreenState extends State<CloudScreen>
|
||||
// ─── 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<Map<String, dynamic>> _syncHistorie = [];
|
||||
|
||||
@@ -179,8 +190,9 @@ class _CloudScreenState extends State<CloudScreen>
|
||||
}
|
||||
|
||||
Future<void> _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<CloudScreen>
|
||||
|
||||
// ─── 🔄 Komplett-Sync ───
|
||||
|
||||
Future<void> _syncAlles() async {
|
||||
if (_syncLaeuft) return;
|
||||
/// 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<bool> _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<CloudScreen>
|
||||
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<CloudScreen>
|
||||
istHeruntergeladen: true,
|
||||
));
|
||||
downloaded++;
|
||||
_syncedItems = downloaded;
|
||||
_syncedItems++;
|
||||
}
|
||||
}
|
||||
// 'lokal' → nichts tun (lokale Version behalten)
|
||||
@@ -283,7 +319,7 @@ class _CloudScreenState extends State<CloudScreen>
|
||||
// (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<CloudScreen>
|
||||
'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 {
|
||||
// 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,16 +465,21 @@ class _CloudScreenState extends State<CloudScreen>
|
||||
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<String?> _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<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
title: const Text('⚠️ Konflikt',
|
||||
style: TextStyle(color: Colors.white, fontSize: 17)),
|
||||
@@ -438,7 +488,7 @@ class _CloudScreenState extends State<CloudScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Titel weichen voneinander ab:',
|
||||
'Titel oder Künstler weichen voneinander ab:',
|
||||
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
|
||||
),
|
||||
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: [
|
||||
@@ -501,9 +563,25 @@ class _CloudScreenState extends State<CloudScreen>
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
).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<CloudScreen>
|
||||
}
|
||||
|
||||
Future<void> _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<CloudScreen>
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user