v2.52.2 — SyncService-Zentralisierung (F3): persistente Sync-Notification, Auto-Sync-Intervall, Schritt-Feedback, 12/25-Zähler

This commit is contained in:
Dustin
2026-08-05 10:59:36 +02:00
parent bf0f544e4b
commit 73aed70edd
2 changed files with 557 additions and 352 deletions
+108 -314
View File
@@ -6,10 +6,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../utils/farb_theme.dart'; import '../utils/farb_theme.dart';
import '../utils/sanitize.dart';
import '../services/cloud_service.dart'; import '../services/cloud_service.dart';
import '../services/favoriten_service.dart'; import '../services/sync_service.dart';
import '../services/favoriten_sync.dart';
import '../database/db_helper.dart'; import '../database/db_helper.dart';
import '../services/melo_logger.dart'; import '../services/melo_logger.dart';
import '../models/song.dart'; import '../models/song.dart';
@@ -38,35 +36,29 @@ class _CloudScreenState extends State<CloudScreen>
bool _statusOk = false; bool _statusOk = false;
bool _serverDatenGeladen = false; bool _serverDatenGeladen = false;
// ─── Favoriten (bidirektionaler Sync) ───
final FavoritenService _favoriten = FavoritenService();
// ─── Sync-Animation ─── // ─── Sync-Animation ───
late final AnimationController _syncAnimController; late final AnimationController _syncAnimController;
// ─── Sync-Einstellungen ─── // ─── Sync-Einstellungen (F3: Aus/1/3/6/12h, persistiert) ───
bool _autoSync = true; /// 0 = Aus (nur manuell). Wird via `cloud_interval` persistiert.
int _syncIntervall = 6; int _syncIntervall = 0;
Timer? _syncTimer;
String _letzterSync = 'Nie'; String _letzterSync = 'Nie';
String? _letzterSyncTs;
String? _naechsterSync; String? _naechsterSync;
// ─── Sync-Fortschritt ─── // ─── Sync-Fortschritt ───
String _syncPhase = ''; String _syncPhase = '';
double _syncFortschritt = 0; double _syncFortschritt = 0;
/// UI-Flag: Fortschritts-Ansicht anzeigen („Im Hintergrund fortsetzen“ /// UI-Flag: Fortschritts-Ansicht anzeigen („Im Hintergrund fortsetzen“
/// setzt NUR dieses Flag zurück). /// setzt NUR dieses Flag zurück — der Sync-Loop läuft im SyncService
/// weiter, dessen globaler Guard verhindert Parallel-Syncs).
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;
int _syncGesamt = 0;
// ─── Konflikt-Batch (MED-3) ─── /// Zentraler Sync-Loop (F3: läuft auch ohne geöffneten Tab weiter,
/// Batch-Entscheidung für alle weiteren Konflikte dieses Sync-Laufs: /// persistente Notification + Abschluss-Benachrichtigung + Chip-Puls).
/// 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (jeden fragen) late final SyncService _sync;
String? _konfliktBatch;
// ─── Sync-Historie (heute) ─── // ─── Sync-Historie (heute) ───
List<Map<String, dynamic>> _syncHistorie = []; List<Map<String, dynamic>> _syncHistorie = [];
@@ -95,6 +87,37 @@ class _CloudScreenState extends State<CloudScreen>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1400), duration: const Duration(milliseconds: 1400),
); );
// Zentraler Sync-Service (F3): läuft auch ohne geöffneten Tab weiter,
// persistente Notification + Abschluss-Benachrichtigung + Chip-Puls.
_sync = SyncService(widget.cloud);
_sync.onFortschritt = _updateSync;
_sync.onFortschrittZaehler = (aktuell, gesamt) {
if (mounted) {
setState(() {
_syncedItems = aktuell;
_syncGesamt = gesamt;
});
}
};
_sync.onStatus = _setzeStatus;
_sync.onHistorie = (dateien, favoriten, playlists) =>
_syncHistorieEintragen(
dateien: dateien, favoriten: favoriten, playlists: playlists);
_sync.onKonflikt = _konfliktDialog;
_sync.onFavoritenAnzahl = (anzahl) {
// NEU-1-Fix: mounted-Guard (Loop läuft evtl. ohne geöffneten Tab)
if (mounted) setState(() => _favServerCount = anzahl);
};
_sync.onPlaylistenLaden = () async {
await _ladePlaylists();
return _serverPlaylists.length;
};
_sync.onNachSync = () async {
await _ladeStatus();
await _ladeServerDaten();
_berechneNaechstenSync();
};
_sync.onSyncEnde = _syncEnde;
// 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();
@@ -114,7 +137,6 @@ class _CloudScreenState extends State<CloudScreen>
@override @override
void dispose() { void dispose() {
widget.cloud.removeListener(_onCloudStatus); widget.cloud.removeListener(_onCloudStatus);
_syncTimer?.cancel();
_syncAnimController.dispose(); _syncAnimController.dispose();
_renameTitleCtrl.dispose(); _renameTitleCtrl.dispose();
_renameArtistCtrl.dispose(); _renameArtistCtrl.dispose();
@@ -138,18 +160,23 @@ class _CloudScreenState extends State<CloudScreen>
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
final letzter = p.getString('cloud_last_sync'); final letzter = p.getString('cloud_last_sync');
final letzterTs = p.getString('cloud_last_sync_ts'); final letzterTs = p.getString('cloud_last_sync_ts');
// F3: cloud_interval (0 = Aus). Migration von cloud_auto (alt: bool).
var intervall = p.getInt('cloud_interval');
if (intervall == null) {
intervall = (p.getBool('cloud_auto') ?? true) ? 6 : 0;
await p.setInt('cloud_interval', intervall);
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_autoSync = p.getBool('cloud_auto') ?? true; _syncIntervall = intervall ?? 0;
_syncIntervall = p.getInt('cloud_interval') ?? 6;
_letzterSync = letzter ?? _letzterSync = letzter ??
(letzterTs != null (letzterTs != null
? _formatZeit(DateTime.tryParse(letzterTs)) ? _formatZeit(DateTime.tryParse(letzterTs))
: 'Nie'); : 'Nie');
_letzterSyncTs = letzterTs;
}); });
} }
_berechneNaechstenSync(); _berechneNaechstenSync();
_starteAutoSync();
} }
Future<void> _ladeServerDaten() async { Future<void> _ladeServerDaten() async {
@@ -162,14 +189,14 @@ class _CloudScreenState extends State<CloudScreen>
} }
void _berechneNaechstenSync() { void _berechneNaechstenSync() {
if (!_autoSync || _syncIntervall == 0) { if (_syncIntervall == 0) {
_naechsterSync = null; _naechsterSync = null;
return; return;
} }
final now = DateTime.now(); final now = DateTime.now();
final last = _letzterSync != 'Nie' final last = _letzterSyncTs != null
? DateTime.tryParse(_letzterSync) ? DateTime.tryParse(_letzterSyncTs!)
: now; : null;
if (last != null) { if (last != null) {
final next = last.add(Duration(hours: _syncIntervall)); final next = last.add(Duration(hours: _syncIntervall));
if (next.isBefore(now)) { if (next.isBefore(now)) {
@@ -177,6 +204,8 @@ class _CloudScreenState extends State<CloudScreen>
} else { } else {
_naechsterSync = _formatZeit(next); _naechsterSync = _formatZeit(next);
} }
} else {
_naechsterSync = null;
} }
} }
@@ -185,214 +214,40 @@ class _CloudScreenState extends State<CloudScreen>
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
} }
void _starteAutoSync() { /// Intervall-Auswahl persistieren + App-weiten Auto-Sync-Timer neu starten.
_syncTimer?.cancel(); Future<void> _intervallSetzen(int stunden) async {
if (!_autoSync || _syncIntervall == 0) return; setState(() => _syncIntervall = stunden);
_syncTimer = Timer.periodic(
Duration(hours: _syncIntervall),
(_) => _autoSyncDurchfuehren(),
);
}
Future<void> _autoSyncDurchfuehren() async {
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 p = await SharedPreferences.getInstance();
final now = DateTime.now(); await p.setInt('cloud_interval', stunden);
final zeit = _formatZeit(now); await p.setBool('cloud_auto', stunden > 0); // Kompatibilität (altes Flag)
await p.setString('cloud_last_sync', zeit); await SyncService.starteAutoSyncTimer();
await p.setString('cloud_last_sync_ts', now.toIso8601String());
if (mounted) {
setState(() {
_letzterSync = zeit;
});
_berechneNaechstenSync(); _berechneNaechstenSync();
} }
}
// ─── 🔄 Komplett-Sync ─── // ─── 🔄 Komplett-Sync ───
/// Komplett-Sync. [automatisch]=true (Auto-Sync-Timer): Konflikte werden /// Komplett-Sync (F3): delegiert an den zentralen [SyncService], der auch
/// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still. /// ohne geöffneten Cloud-Tab weiterläuft (persistente Notification,
/// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein /// Abschluss-Benachrichtigung, ☁️-Chip-Puls, globaler Doppel-Sync-Guard).
/// Sync läuft (_syncProzessLaeuft-Guard).
Future<bool> _syncAlles({bool automatisch = false}) async { Future<bool> _syncAlles({bool automatisch = false}) async {
if (_syncProzessLaeuft) return false; if (SyncService.laeuftGlobal) return false;
_syncProzessLaeuft = true;
_konfliktBatch = null;
if (mounted) { if (mounted) {
setState(() { setState(() {
_syncLaeuft = true; _syncLaeuft = true;
_syncPhase = 'Verbinde...'; _syncPhase = 'Verbinde...';
_syncFortschritt = 0; _syncFortschritt = 0;
_syncedItems = 0; _syncedItems = 0;
_syncGesamt = 0;
}); });
} }
_syncAnimController.repeat(); _syncAnimController.repeat();
return _sync.syncAlles(automatisch: automatisch);
try {
// Phase 1: Songs synchronisieren
_updateSync('Vergleiche Songs...', 0.1);
final serverSongs = await widget.cloud.listSongs();
final db = DbHelper();
final dir =
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
int downloaded = 0;
int totalNew = 0;
// Zähle neue Songs
for (final song in serverSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
final existing = await db.songNachCloudId(sid);
if (existing == null) totalNew++;
} }
// Downloade neue Songs + löse Konflikte (Titel lokal ≠ Server) /// Immer am Loop-Ende (auch bei Fehler / nach „Im Hintergrund fortsetzen“):
int processed = 0; /// Animation stoppen, Sync-Ansicht schließen. Controller-Zugriffe abgesichert,
for (final song in serverSongs) { /// falls der Screen während des Hintergrund-Syncs disposed wurde (MED-4).
final sid = song['id']?.toString() ?? ''; void _syncEnde() {
if (sid.isEmpty) continue;
final title = (song['title'] ?? 'unknown').toString();
final existing = await db.songNachCloudId(sid);
if (existing != null) {
// Konfliktprüfung: Server-Titel ODER Server-Künstler weichen ab
final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim();
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: serverKuenstler,
);
_syncedItems++;
downloaded++;
} else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel);
var dest = '${dir.path}/$safeTitle';
if (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle (Server)';
}
if (await widget.cloud.download(sid, dest)) {
await db.songEinfuegen(Song(
titel: serverTitel,
kuenstler: (song['artist']?.toString() ?? '').trim(),
dauerSekunden: 0,
dateiPfad: dest,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
));
downloaded++;
_syncedItems++;
}
}
// 'lokal' → nichts tun (lokale Version behalten)
}
processed++;
continue;
}
_updateSync('Download: $title...',
0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1)));
final safeTitle = sanitizeDateiname(title);
final dest = '${dir.path}/$safeTitle';
if (await widget.cloud.download(sid, dest)) {
downloaded++;
// In DB eintragen mit cloud_id
// (vereinfacht: ID3-Reader würde Titel extrahieren)
}
processed++;
_syncedItems++;
await Future.delayed(
const Duration(milliseconds: 50)); // UI-Update erlauben
}
// Phase 2: Favoriten bidirektional synchronisieren
// Root-Cause-Fix (Sprint D): Vorher wurden die Server-Favoriten nur
// zurückgespiegelt (Server → Server) — lokale ⭐-Toggles gingen
// verloren. Jetzt: Vereinigung lokal Server, Push + lokales Markieren.
_updateSync('Sync Favoriten…', 0.55);
final serverFavs = await widget.cloud.getFavorites();
final serverIds = serverFavs
.map((f) => f['id']?.toString() ?? '')
.where((id) => id.isNotEmpty)
.toSet();
final lokalIds = await _favoriten.favoritenCloudIds();
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
// bleiben erhalten (kein Datenverlust in beide Richtungen)
await widget.cloud.syncFavorites(merged);
// Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song
// lokal existiert); `server_favorites` spiegelt den Merge-Zustand
for (final cid in serverIds) {
await _favoriten.merkeCloudFavorit(cid);
}
await db.serverFavoritesSet(merged);
setState(() => _favServerCount = merged.length);
_syncedItems += merged.length;
// Phase 3: Playlisten abgleichen
_updateSync('Lade Playlisten...', 0.7);
await _ladePlaylists();
// Phase 4: Sync-Metadaten aktualisieren
_updateSync('Speichere Sync-Zeitpunkt...', 0.9);
await widget.cloud.syncAll();
final now = DateTime.now().toIso8601String();
await db.syncMetaSet('last_full_sync', now);
_updateSync('Fertig!', 1.0);
if (mounted) {
setState(() {
_syncedItems = downloaded + merged.length;
});
_setzeStatus(
'$downloaded Songs + ${merged.length} Favoriten synchronisiert',
ok: true);
// Sync-Historie fürs Dashboard festhalten
await _syncHistorieEintragen(
dateien: downloaded,
favoriten: merged.length,
playlists: _serverPlaylists.length,
);
await _ladeStatus();
}
MeloLogger().aktion('cloud_sync_all', {
'downloaded': downloaded,
'favorites': merged.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 { try {
_syncAnimController.stop(); _syncAnimController.stop();
_syncAnimController.value = 0; _syncAnimController.value = 0;
@@ -406,7 +261,6 @@ class _CloudScreenState extends State<CloudScreen>
}); });
} }
} }
}
// ─── 📜 Sync-Historie (fürs Dashboard) ─── // ─── 📜 Sync-Historie (fürs Dashboard) ───
@@ -486,13 +340,15 @@ class _CloudScreenState extends State<CloudScreen>
/// Dismissable (Tap außerhalb = Abbruch → lokale Version behalten, der /// Dismissable (Tap außerhalb = Abbruch → lokale Version behalten, der
/// Sync läuft weiter). Batch-Optionen: Checkbox „Für alle übernehmen“ + /// Sync läuft weiter). Batch-Optionen: Checkbox „Für alle übernehmen“ +
/// Button „Alle weiteren überspringen“. /// Button „Alle weiteren überspringen“.
/// Rückgabe: 'lokal' | 'server' | 'beide' | 'ueberspringen' | null /// Rückgabe: [KonfliktErgebnis] ('lokal' | 'server' | 'beide' |
Future<String?> _konfliktDialog(Song lokal, Map serverSong) async { /// 'ueberspringen') oder null bei Abbruch. Die Batch-Logik übernimmt der
/// SyncService (MED-3) — der Dialog liefert nur wahl + fuerAlle.
Future<KonfliktErgebnis?> _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; var fuerAlle = false;
return showDialog<String>( final wahl = await showDialog<String>(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@@ -581,24 +437,18 @@ class _CloudScreenState extends State<CloudScreen>
style: TextStyle(color: MeloTheme.rot)), style: TextStyle(color: MeloTheme.rot)),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () => Navigator.pop(ctx, 'ueberspringen'),
_konfliktBatch = 'ueberspringen';
Navigator.pop(ctx, 'ueberspringen');
},
child: const Text('Alle weiteren überspringen', child: const Text('Alle weiteren überspringen',
style: TextStyle(color: MeloTheme.textSekundaer)), style: TextStyle(color: MeloTheme.textSekundaer)),
), ),
], ],
), ),
), ),
).then((wahl) { );
// Batch merken: gewählte Entscheidung auf alle restlichen Konflikte if (wahl == null) return null;
// dieses Sync-Laufs anwenden. // 'ueberspringen' = alle weiteren überspringen → fuerAlle=true, damit der
if (wahl != null && fuerAlle && _konfliktBatch == null) { // SyncService die Batch-Entscheidung (keep local) übernimmt.
_konfliktBatch = wahl; return KonfliktErgebnis(wahl, fuerAlle: wahl == 'ueberspringen' || fuerAlle);
}
return wahl;
});
} }
void _updateSync(String phase, double progress) { void _updateSync(String phase, double progress) {
@@ -613,6 +463,7 @@ class _CloudScreenState extends State<CloudScreen>
// ─── 📋 Playlists ─── // ─── 📋 Playlists ───
Future<void> _ladePlaylists() async { Future<void> _ladePlaylists() async {
if (!mounted) return; // NEU-1: Loop kann ohne geöffneten Tab laufen
setState(() => _ladtPlaylists = true); setState(() => _ladtPlaylists = true);
try { try {
final pls = await widget.cloud.getPlaylists(); final pls = await widget.cloud.getPlaylists();
@@ -706,6 +557,7 @@ class _CloudScreenState extends State<CloudScreen>
// ─── ⭐ Favoriten ─── // ─── ⭐ Favoriten ───
Future<void> _ladeFavorites() async { Future<void> _ladeFavorites() async {
if (!mounted) return; // NEU-1: Loop kann ohne geöffneten Tab laufen
setState(() => _ladtFavorites = true); setState(() => _ladtFavorites = true);
try { try {
final favs = await widget.cloud.getFavorites(); final favs = await widget.cloud.getFavorites();
@@ -829,7 +681,7 @@ class _CloudScreenState extends State<CloudScreen>
} }
Future<void> _download() async { Future<void> _download() async {
if (_syncProzessLaeuft) { if (SyncService.laeuftGlobal) {
_setzeStatus('Sync läuft bereits im Hintergrund', ok: false); _setzeStatus('Sync läuft bereits im Hintergrund', ok: false);
return; return;
} }
@@ -1075,7 +927,9 @@ class _CloudScreenState extends State<CloudScreen>
if (_syncedItems > 0) ...[ if (_syncedItems > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'$_syncedItems Elemente synchronisiert', _syncGesamt > 0
? '$_syncedItems / $_syncGesamt synchronisiert'
: '$_syncedItems Elemente synchronisiert',
style: const TextStyle( style: const TextStyle(
color: Color(0xFFA5D6A7), fontSize: 12), color: Color(0xFFA5D6A7), fontSize: 12),
), ),
@@ -1084,9 +938,10 @@ class _CloudScreenState extends State<CloudScreen>
TextButton( TextButton(
onPressed: () { onPressed: () {
// Nur die UI-Persistenz ändern: Fortschritts-Ansicht schließen. // Nur die UI-Persistenz ändern: Fortschritts-Ansicht schließen.
// Der Sync-Loop läuft weiter und der echte Guard // Der Sync-Loop läuft im SyncService weiter und der globale
// (_syncProzessLaeuft) bleibt gesperrt, bis der Loop fertig // Guard (SyncService.laeuftGlobal) bleibt gesperrt, bis der
// ist — ein erneuter „Jetzt Syncen“ startet KEINEN Parallel-Sync. // Loop fertig ist — ein erneuter „Jetzt Syncen“ startet KEINEN
// Parallel-Sync. Sichtbar bleibt er via Notification + Chip-Puls.
setState(() => _syncLaeuft = false); setState(() => _syncLaeuft = false);
}, },
child: const Text('Im Hintergrund fortsetzen', child: const Text('Im Hintergrund fortsetzen',
@@ -1128,7 +983,9 @@ class _CloudScreenState extends State<CloudScreen>
child: _aktionsButton( child: _aktionsButton(
icon: Icons.sync_rounded, icon: Icons.sync_rounded,
label: 'Jetzt Syncen', label: 'Jetzt Syncen',
beschreibung: _autoSync ? 'Sofort synchronisieren' : 'Manuell syncen', beschreibung: _syncIntervall > 0
? 'Sofort synchronisieren'
: 'Manuell syncen',
onTap: _download, onTap: _download,
)), )),
], ],
@@ -1359,7 +1216,10 @@ class _CloudScreenState extends State<CloudScreen>
); );
} }
/// Auto-Sync-Intervall-Auswahl (F3): Aus/1h/3h/6h/12h.
/// 0 = Aus (nur manuell) — ersetzt den alten Manuell/Auto-Toggle.
Widget _syncModusAuswahl() { Widget _syncModusAuswahl() {
const optionen = [0, 1, 3, 6, 12];
return Container( return Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1370,22 +1230,9 @@ class _CloudScreenState extends State<CloudScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Manuell / Auto Toggle
Row(
children: [
Expanded(child: _syncModusButton('Manuell', false, Icons.touch_app,
!_autoSync)),
const SizedBox(width: 4),
Expanded(child: _syncModusButton('Auto', true, Icons.sync,
_autoSync)),
],
),
// Intervall-Auswahl (nur wenn Auto aktiv)
if (_autoSync) ...[
const SizedBox(height: 4),
const Padding( const Padding(
padding: EdgeInsets.only(left: 8, top: 4), padding: EdgeInsets.only(left: 8, top: 4),
child: Text('Intervall', child: Text('Auto-Sync-Intervall',
style: TextStyle( style: TextStyle(
color: MeloTheme.textSekundaer, color: MeloTheme.textSekundaer,
fontSize: 11, fontSize: 11,
@@ -1393,32 +1240,23 @@ class _CloudScreenState extends State<CloudScreen>
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
children: [3, 6, 12].map((h) { children: optionen.map((h) {
final aktiv = _syncIntervall == h; final aktiv = _syncIntervall == h;
final label = h == 0 ? 'Aus' : '${h}h';
return Expanded( return Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(right: h != 12 ? 4 : 0),
right: h != 12 ? 4 : 0),
child: GestureDetector( child: GestureDetector(
onTap: () async { onTap: () => _intervallSetzen(h),
setState(() => _syncIntervall = h);
(await SharedPreferences.getInstance())
.setInt('cloud_interval', h);
_starteAutoSync();
},
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(vertical: 10),
vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: aktiv color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2,
? MeloTheme.rot borderRadius: BorderRadius.circular(10),
: MeloTheme.dunkel2,
borderRadius:
BorderRadius.circular(10),
), ),
child: Center( child: Center(
child: Text( child: Text(
'${h}h', label,
style: TextStyle( style: TextStyle(
color: aktiv color: aktiv
? Colors.white ? Colors.white
@@ -1435,50 +1273,6 @@ class _CloudScreenState extends State<CloudScreen>
}).toList(), }).toList(),
), ),
], ],
],
),
);
}
Widget _syncModusButton(
String label, bool auto, IconData icon, bool aktiv) {
return GestureDetector(
onTap: () async {
if (auto) {
setState(() => _autoSync = true);
(await SharedPreferences.getInstance())
.setBool('cloud_auto', true);
_starteAutoSync();
_berechneNaechstenSync();
} else {
setState(() => _autoSync = false);
(await SharedPreferences.getInstance())
.setBool('cloud_auto', false);
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 14,
color: aktiv ? Colors.white : MeloTheme.textSekundaer),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
), ),
); );
} }
+411
View File
@@ -0,0 +1,411 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../database/db_helper.dart';
import '../models/song.dart';
import '../utils/sanitize.dart';
import 'cloud_service.dart';
import 'favoriten_service.dart';
import 'favoriten_sync.dart';
import 'melo_logger.dart';
import '../main.dart'; // notificationsPlugin
/// Ergebnis des Konflikt-Dialogs (UI-Callback aus dem Cloud-Tab):
/// [wahl] = 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (Abbruch).
/// [fuerAlle] = Checkbox „Für alle weiteren Konflikte übernehmen“.
class KonfliktErgebnis {
final String? wahl;
final bool fuerAlle;
const KonfliktErgebnis(this.wahl, {this.fuerAlle = false});
}
/// Zentraler Cloud-Sync (Sprint D, v2.52.2).
///
/// Der komplette Sync-Loop (Songs → Favoriten-Merge → Playlisten → Metadaten)
/// ist hier herausgelöst aus dem CloudScreen, damit er auch OHNE geöffneten
/// Cloud-Tab laufen kann (Auto-Sync-Timer, app-weit) und nach
/// „Im Hintergrund fortsetzen“ sichtbar bleibt:
///
/// - **Persistente Sync-Notification** („Synchronisiere… 12/25“) mit Fortschritt
/// - **Abschluss-Notification** („Sync fertig: X Songs, Y Favoriten“, tippbar)
/// - **☁️-Musikserver-Chip pulsiert** während des Syncs ([laeuftNotifier])
/// - **Echter Doppel-Sync-Guard** ([laeuftGlobal]) über alle Instanzen hinweg
/// - **Auto-Sync-Intervall** Aus/1h/3h/6h/12h ([starteAutoSyncTimer]) —
/// KEIN Sync beim App-Start, nur wenn der letzte Sync älter als das
/// Intervall ist ([istSyncFaellig]).
///
/// UI-Feedback läuft über Callbacks ([onFortschritt], [onStatus], …) — ohne
/// UI (Hintergrund/Auto-Sync) sind sie No-ops, die Notifications übernehmen.
class SyncService {
SyncService(this.cloud)
: _db = DbHelper(),
_favoriten = FavoritenService();
final CloudService cloud;
final DbHelper _db;
final FavoritenService _favoriten;
// ─── Globale Laufzeit-Signale ───
/// Echter Doppel-Sync-Guard: bleibt true, solange IRGENDEIN Sync-Loop läuft
/// (auch im Hintergrund nach „Im Hintergrund fortsetzen“ oder via
/// Auto-Sync-Timer). Statisch → alle SyncService-Instanzen teilen ihn.
static bool _laeuftGlobal = false;
static bool get laeuftGlobal => _laeuftGlobal;
/// UI-Signal für den ☁️-Musikserver-Chip (pulsiert während des Syncs).
/// ValueNotifier, damit der Home-Screen (ListenableBuilder) mitrebuildet.
static final ValueNotifier<bool> laeuftNotifier = ValueNotifier<bool>(false);
// ─── Auto-Sync-Timer (app-weit, F3) ───
static Timer? _autoSyncTimer;
/// Startet den Auto-Sync-Timer neu anhand der gespeicherten Einstellung
/// `cloud_interval` (0 = Aus). Erster Tick erst NACH dem Intervall —
/// kein Sync beim App-Start. Wird beim App-Start und bei jeder
/// Intervall-Änderung aufgerufen.
static Future<void> starteAutoSyncTimer() async {
_autoSyncTimer?.cancel();
_autoSyncTimer = null;
final p = await SharedPreferences.getInstance();
final intervall = p.getInt('cloud_interval') ?? 0;
if (intervall <= 0) return;
_autoSyncTimer = Timer.periodic(Duration(hours: intervall), (_) {
final service = SyncService(CloudService());
service._autoSyncTick(intervall);
});
}
/// F3c: Auto-Sync nur wenn der letzte Sync älter als das Intervall ist.
/// [letzterSync] = null (nie gesynct) → fällig.
static bool istSyncFaellig(DateTime? letzterSync, int intervallStunden,
{DateTime? jetzt}) {
if (intervallStunden <= 0) return false;
final j = jetzt ?? DateTime.now();
final l = letzterSync;
if (l == null) return true;
return j.difference(l).inHours >= intervallStunden;
}
Future<void> _autoSyncTick(int intervallStunden) async {
if (_laeuftGlobal) return;
final p = await SharedPreferences.getInstance();
final lastTs = p.getString('cloud_last_sync_ts');
final letzter = lastTs != null ? DateTime.tryParse(lastTs) : null;
if (!istSyncFaellig(letzter, intervallStunden)) return;
MeloLogger().aktion('auto_sync_timer', {'intervall': intervallStunden});
await syncAlles(automatisch: true);
}
// ─── UI-Callbacks (CloudScreen verdrahtet sie; ohne UI = No-op) ───
/// Phase + Fortschritt (0.01.0) für die Fortschritts-Ansicht im Cloud-Tab.
void Function(String phase, double progress)? onFortschritt;
/// „12/25“-Zähler (aktuelle Position / Gesamtanzahl).
void Function(int aktuell, int gesamt)? onFortschrittZaehler;
/// Transiente Status-Meldung (grün/rot).
void Function(String msg, {bool ok})? onStatus;
/// Sync-Historie-Eintrag (Dateien, Favoriten, Playlisten).
void Function(int dateien, int favoriten, int playlists)? onHistorie;
/// Konflikt-Dialog (nur bei manuellem Sync — Auto-Sync gewinnt still).
Future<KonfliktErgebnis?> Function(Song lokal, Map serverSong)? onKonflikt;
/// Server-Favoriten-Anzahl nach dem Merge.
void Function(int anzahl)? onFavoritenAnzahl;
/// Playlisten vom Server neu laden (für Sync-Phase 3 + Historie).
/// Rückgabe: Anzahl der geladenen Playlisten.
Future<int> Function()? onPlaylistenLaden;
/// Nach erfolgreichem Sync: Status/Playlisten/Favoriten/letzten Sync neu laden.
Future<void> Function()? onNachSync;
/// Immer am Ende (auch bei Fehler): Animation stoppen, Sync-Ansicht schließen.
void Function()? onSyncEnde;
// ─── Notifications ───
static const int _syncNotifyId = 300;
static const String _syncChannelId = 'de.baka.melo.sync';
/// 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 (globaler Guard).
Future<bool> syncAlles({bool automatisch = false}) async {
if (_laeuftGlobal) return false;
_laeuftGlobal = true;
laeuftNotifier.value = true;
String? konfliktBatch;
try {
_zeigeSyncNotification('Verbinde…', 0, 0);
onFortschritt?.call('Verbinde...', 0);
onFortschrittZaehler?.call(0, 0);
// ── Phase 1: Songs ──
onFortschritt?.call('Lade Songs…', 0.1);
final serverSongs = await cloud.listSongs();
final dir =
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
int downloaded = 0;
int totalNew = 0;
// Zähle neue Songs
for (final song in serverSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
final existing = await _db.songNachCloudId(sid);
if (existing == null) totalNew++;
}
// Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server)
int processed = 0;
for (final song in serverSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
processed++;
// Fortschritt je Song (auch für bereits vorhandene) — „12/25“
_zeigeSyncNotification('Synchronisiere…', processed, serverSongs.length);
onFortschrittZaehler?.call(processed, serverSongs.length);
final title = (song['title'] ?? 'unknown').toString();
final existing = await _db.songNachCloudId(sid);
if (existing != null) {
final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim();
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 {
final ergebnis =
onKonflikt != null ? await onKonflikt!(existing, song) : null;
if (ergebnis != null) {
wahl = ergebnis.wahl;
if (ergebnis.fuerAlle &&
konfliktBatch == null &&
wahl != null) {
konfliktBatch = wahl;
}
}
}
if (wahl == 'server') {
await _db.cloudMetadatenAktualisieren(existing.id!,
title: serverTitel, artist: serverKuenstler);
downloaded++;
} else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel);
var dest = '${dir.path}/$safeTitle';
if (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle (Server)';
}
if (await cloud.download(sid, dest)) {
await _db.songEinfuegen(Song(
titel: serverTitel,
kuenstler: serverKuenstler,
dauerSekunden: 0,
dateiPfad: dest,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
));
downloaded++;
}
}
// 'lokal' → nichts tun (lokale Version behalten)
}
continue;
}
onFortschritt?.call('Download: $title',
0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1)));
final safeTitle = sanitizeDateiname(title);
final dest = '${dir.path}/$safeTitle';
if (await cloud.download(sid, dest)) {
downloaded++;
// In DB eintragen mit cloud_id
// (vereinfacht: ID3-Reader würde Titel extrahieren)
}
await Future.delayed(
const Duration(milliseconds: 50)); // UI-Update erlauben
}
// ── Phase 2: Favoriten bidirektional (Merge lokal Server) ──
onFortschritt?.call('Sync Favoriten…', 0.55);
final serverFavs = await cloud.getFavorites();
final serverIds = serverFavs
.map((f) => f['id']?.toString() ?? '')
.where((id) => id.isNotEmpty)
.toSet();
final lokalIds = await _favoriten.favoritenCloudIds();
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
// bleiben erhalten (kein Datenverlust in beide Richtungen)
await cloud.syncFavorites(merged);
// Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song
// lokal existiert); `server_favorites` spiegelt den Merge-Zustand
for (final cid in serverIds) {
await _favoriten.merkeCloudFavorit(cid);
}
await _db.serverFavoritesSet(merged);
onFavoritenAnzahl?.call(merged.length);
onFortschrittZaehler?.call(downloaded + merged.length, serverSongs.length);
// ── Phase 3: Playlisten ──
onFortschritt?.call('Sync Playlisten…', 0.7);
int playlistCount = 0;
if (onPlaylistenLaden != null) {
try {
playlistCount = await onPlaylistenLaden!();
} catch (e) {
MeloLogger().fehler('cloud_playlisten_sync', e);
}
}
// ── Phase 4: Sync-Metadaten + letzter Sync ──
onFortschritt?.call('Speichere Sync-Zeitpunkt...', 0.9);
await cloud.syncAll();
final now = DateTime.now().toIso8601String();
await _db.syncMetaSet('last_full_sync', now);
// Nur bei Erfolg persistieren (Auto-Sync-Fälligkeit hängt daran)
final p = await SharedPreferences.getInstance();
final zeit = _formatZeit(DateTime.now());
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now);
onFortschritt?.call('Fertig!', 1.0);
onStatus?.call(
'$downloaded Songs + ${merged.length} Favoriten synchronisiert',
ok: true);
// UI-Nachladen (Status, Playlisten, Favoriten, letzter Sync)
await onNachSync?.call();
onHistorie?.call(downloaded, merged.length, playlistCount);
_zeigeSyncFertigNotification(downloaded, merged.length);
MeloLogger().aktion('cloud_sync_all', {
'downloaded': downloaded,
'favorites': merged.length,
'playlists': playlistCount,
});
return true;
} catch (e) {
MeloLogger().fehler('cloud_sync_all', e);
onStatus?.call('Sync-Fehler: $e', ok: false);
_zeigeSyncFehlerNotification();
return false;
} finally {
// Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während
// des Syncs verlassen wurde („Im Hintergrund fortsetzen“).
_laeuftGlobal = false;
laeuftNotifier.value = false;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {
// Plugin kann beim App-Exit bereits disposed sein
}
onSyncEnde?.call();
}
}
// ─── Notifications ───
/// Persistente Fortschritts-Notification (nicht wegwischbar, ongoing).
void _zeigeSyncNotification(String body, int aktuell, int gesamt) {
if (!Platform.isAndroid) return;
final maxP = gesamt > 0 ? gesamt : 1;
final p = aktuell > maxP ? maxP : aktuell;
notificationsPlugin.show(
id: _syncNotifyId,
title: 'Synchronisiere…',
body: gesamt > 0 ? '$aktuell / $gesamt Songs' : body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: 'Cloud-Sync-Fortschritt',
importance: Importance.low,
priority: Priority.low,
onlyAlertOnce: true,
showProgress: true,
maxProgress: maxP,
progress: p,
ongoing: true,
autoCancel: false,
),
),
);
}
/// Abschluss-Notification — tippbar (öffnet den Cloud-Tab, payload
/// 'sync_fertig' wird in main.dart behandelt).
void _zeigeSyncFertigNotification(int songs, int favoriten) {
if (!Platform.isAndroid) return;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {}
notificationsPlugin.show(
id: _syncNotifyId + 1,
title: 'Sync fertig',
body: '$songs Songs, $favoriten Favoriten synchronisiert ✅',
payload: 'sync_fertig',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: 'Cloud-Sync-Abschluss',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
),
),
);
}
/// Fehler-Notification (tippbar, öffnet den Cloud-Tab).
void _zeigeSyncFehlerNotification() {
if (!Platform.isAndroid) return;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {}
notificationsPlugin.show(
id: _syncNotifyId + 1,
title: 'Sync fehlgeschlagen',
body: 'Bitte erneut versuchen ❌',
payload: 'sync_fehler',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: 'Cloud-Sync-Abschluss',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
),
),
);
}
String _formatZeit(DateTime? dt) {
if (dt == null) return 'Nie';
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}