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:
+179
-94
@@ -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.
|
||||||
setState(() {
|
/// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein
|
||||||
_syncLaeuft = true;
|
/// Sync läuft (_syncProzessLaeuft-Guard).
|
||||||
_syncPhase = 'Verbinde...';
|
Future<bool> _syncAlles({bool automatisch = false}) async {
|
||||||
_syncFortschritt = 0;
|
if (_syncProzessLaeuft) return false;
|
||||||
_syncedItems = 0;
|
_syncProzessLaeuft = true;
|
||||||
});
|
_konfliktBatch = null;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_syncLaeuft = true;
|
||||||
|
_syncPhase = 'Verbinde...';
|
||||||
|
_syncFortschritt = 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 {
|
||||||
_syncAnimController.stop();
|
// Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während
|
||||||
_syncAnimController.value = 0;
|
// 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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_syncLaeuft = false;
|
_syncLaeuft = false;
|
||||||
@@ -420,90 +465,123 @@ 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(
|
||||||
backgroundColor: MeloTheme.dunkel1,
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: const Text('⚠️ Konflikt',
|
backgroundColor: MeloTheme.dunkel1,
|
||||||
style: TextStyle(color: Colors.white, fontSize: 17)),
|
title: const Text('⚠️ Konflikt',
|
||||||
content: Column(
|
style: TextStyle(color: Colors.white, fontSize: 17)),
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Text(
|
children: [
|
||||||
'Titel weichen voneinander ab:',
|
const Text(
|
||||||
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
|
'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),
|
TextButton(
|
||||||
Container(
|
onPressed: () => Navigator.pop(ctx, 'beide'),
|
||||||
width: double.infinity,
|
child: const Text('Beide behalten',
|
||||||
padding: const EdgeInsets.all(10),
|
style: TextStyle(color: MeloTheme.textSekundaer)),
|
||||||
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),
|
TextButton(
|
||||||
Container(
|
onPressed: () => Navigator.pop(ctx, 'server'),
|
||||||
width: double.infinity,
|
child: const Text('Server übernehmen',
|
||||||
padding: const EdgeInsets.all(10),
|
style: TextStyle(color: MeloTheme.rot)),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: MeloTheme.dunkel2,
|
TextButton(
|
||||||
borderRadius: BorderRadius.circular(10),
|
onPressed: () {
|
||||||
),
|
_konfliktBatch = 'ueberspringen';
|
||||||
child: Column(
|
Navigator.pop(ctx, 'ueberspringen');
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
},
|
||||||
children: [
|
child: const Text('Alle weiteren überspringen',
|
||||||
const Text('☁️ Server',
|
style: TextStyle(color: MeloTheme.textSekundaer)),
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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) {
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user