v2.55 — Echtzeit-Cloud-Sync (SSE) mit 3 Modi: Echtzeit/Manuell/Zeit
This commit is contained in:
@@ -21,6 +21,7 @@ import '../services/cloud_service.dart';
|
|||||||
import '../services/player_service.dart';
|
import '../services/player_service.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
|
import '../services/realtime_sync_service.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
class MeloHome extends StatefulWidget {
|
class MeloHome extends StatefulWidget {
|
||||||
@@ -30,7 +31,8 @@ class MeloHome extends StatefulWidget {
|
|||||||
State<MeloHome> createState() => _MeloHomeState();
|
State<MeloHome> createState() => _MeloHomeState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MeloHomeState extends State<MeloHome> {
|
class _MeloHomeState extends State<MeloHome> with WidgetsBindingObserver {
|
||||||
|
final RealtimeSyncService _realtimeSync = RealtimeSyncService();
|
||||||
final MeloHomeViewModel _vm = MeloHomeViewModel();
|
final MeloHomeViewModel _vm = MeloHomeViewModel();
|
||||||
final CloudService _cloud = CloudService();
|
final CloudService _cloud = CloudService();
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_vm.ladeSongs();
|
_vm.ladeSongs();
|
||||||
// Gespeicherte Navidrome-Zugangsdaten laden → Musikserver-Statuspunkt
|
// Gespeicherte Navidrome-Zugangsdaten laden → Musikserver-Statuspunkt
|
||||||
_vm.navidrome.ladeGespeicherteZugangsdaten().then((_) {
|
_vm.navidrome.ladeGespeicherteZugangsdaten().then((_) {
|
||||||
@@ -55,17 +58,37 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
});
|
});
|
||||||
// Cloud-Verbindung beim App-Start herstellen (feuern-und-vergessen).
|
// Cloud-Verbindung beim App-Start herstellen (feuern-und-vergessen).
|
||||||
_cloud.verbinde();
|
_cloud.verbinde();
|
||||||
|
// Realtime-Sync starten (SSE-Push, wenn Modus=Echtzeit)
|
||||||
|
_realtimeSync.starteWennAktiviert();
|
||||||
// Tipp auf die Sync-Abschluss-/Fehler-Notification → Cloud-Tab öffnen
|
// Tipp auf die Sync-Abschluss-/Fehler-Notification → Cloud-Tab öffnen
|
||||||
SyncService.syncBenachrichtigungGetippt.addListener(_syncNotifGetippt);
|
SyncService.syncBenachrichtigungGetippt.addListener(_syncNotifGetippt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
SyncService.syncBenachrichtigungGetippt.removeListener(_syncNotifGetippt);
|
SyncService.syncBenachrichtigungGetippt.removeListener(_syncNotifGetippt);
|
||||||
|
_realtimeSync.stoppe();
|
||||||
_vm.dispose();
|
_vm.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
super.didChangeAppLifecycleState(state);
|
||||||
|
switch (state) {
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
// App in den Vordergrund → Realtime fortsetzen
|
||||||
|
_realtimeSync.fortsetzen();
|
||||||
|
case AppLifecycleState.paused:
|
||||||
|
case AppLifecycleState.detached:
|
||||||
|
case AppLifecycleState.inactive:
|
||||||
|
case AppLifecycleState.hidden:
|
||||||
|
// App im Hintergrund → Realtime pausieren (Batterie sparen)
|
||||||
|
_realtimeSync.pausiere();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Öffnet den Cloud-Tab, wenn die Sync-Notification getippt wurde.
|
/// Öffnet den Cloud-Tab, wenn die Sync-Notification getippt wurde.
|
||||||
void _syncNotifGetippt() {
|
void _syncNotifGetippt() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../services/auth_service.dart';
|
|||||||
import '../services/cloud_service.dart';
|
import '../services/cloud_service.dart';
|
||||||
import '../services/player_service.dart';
|
import '../services/player_service.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
|
import '../services/realtime_sync_service.dart';
|
||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
import 'cloud_screen.dart';
|
import 'cloud_screen.dart';
|
||||||
import 'recap_screen.dart';
|
import 'recap_screen.dart';
|
||||||
@@ -25,12 +26,22 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
// ─── Einstellungen (aus SharedPreferences geladen) ───
|
// ─── Einstellungen (aus SharedPreferences geladen) ───
|
||||||
int _syncIntervall = 0; // 0 = Aus, sonst Stunden
|
String _syncModus = 'realtime'; // 'realtime' | 'manual' | 'interval'
|
||||||
|
int _cloudIntervallStunden = 48; // 48/72/120/168/336
|
||||||
int _sleepDefaultMin = 0; // 0 = Aus
|
int _sleepDefaultMin = 0; // 0 = Aus
|
||||||
double _geschwindigkeit = 1.0;
|
double _geschwindigkeit = 1.0;
|
||||||
Color _akzent = MeloTheme.akzent;
|
Color _akzent = MeloTheme.akzent;
|
||||||
Map<String, int> _speicherInfo = const {'anzahl': 0, 'bytes': 0};
|
Map<String, int> _speicherInfo = const {'anzahl': 0, 'bytes': 0};
|
||||||
|
|
||||||
|
/// Verfügbare Zeit-Intervalle für Modus 3 (in Stunden).
|
||||||
|
static const _intervallOptionen = {
|
||||||
|
48: '2 Tage',
|
||||||
|
72: '3 Tage',
|
||||||
|
120: '5 Tage',
|
||||||
|
168: '1 Woche',
|
||||||
|
336: '2 Wochen',
|
||||||
|
};
|
||||||
|
|
||||||
static const _akzentFarben = [
|
static const _akzentFarben = [
|
||||||
Color(0xFFCC0000), // Rot (Standard)
|
Color(0xFFCC0000), // Rot (Standard)
|
||||||
Color(0xFF00B8A9), // Türkis
|
Color(0xFF00B8A9), // Türkis
|
||||||
@@ -53,9 +64,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final speed = p.getDouble('playback_speed') ?? 1.0;
|
final speed = p.getDouble('playback_speed') ?? 1.0;
|
||||||
final akzentHex =
|
final akzentHex =
|
||||||
p.getString(UserEffekt.akzentKey(AuthService().benutzer));
|
p.getString(UserEffekt.akzentKey(AuthService().benutzer));
|
||||||
|
// Sync-Modus laden (mit Migration von altem cloud_interval)
|
||||||
|
final modus = p.getString('sync_modus') ?? 'realtime';
|
||||||
|
int intervallStunden = p.getInt('cloud_interval_stunden') ?? 0;
|
||||||
|
if (intervallStunden <= 0) {
|
||||||
|
// Migration: alter cloud_interval (1/3/6/12h) → 48h default
|
||||||
|
final alt = p.getInt('cloud_interval') ?? 0;
|
||||||
|
if (alt == 1 || alt == 3 || alt == 6 || alt == 12) {
|
||||||
|
intervallStunden = 48;
|
||||||
|
await p.setInt('cloud_interval_stunden', 48);
|
||||||
|
} else {
|
||||||
|
intervallStunden = 48;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prüfe, ob der Modus gültig ist
|
||||||
|
if (!['realtime', 'manual', 'interval'].contains(modus)) {
|
||||||
|
await p.setString('sync_modus', 'realtime');
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_syncIntervall = p.getInt('cloud_interval') ?? 0;
|
_syncModus = modus;
|
||||||
|
_cloudIntervallStunden = intervallStunden;
|
||||||
_sleepDefaultMin = p.getInt('sleep_timer_default_min') ?? 0;
|
_sleepDefaultMin = p.getInt('sleep_timer_default_min') ?? 0;
|
||||||
_geschwindigkeit = speed;
|
_geschwindigkeit = speed;
|
||||||
_akzent = UserEffekt.farbeAusHex(akzentHex) ?? MeloTheme.akzent;
|
_akzent = UserEffekt.farbeAusHex(akzentHex) ?? MeloTheme.akzent;
|
||||||
@@ -72,12 +101,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
// ─── Setter (persistieren + anwenden) ───
|
// ─── Setter (persistieren + anwenden) ───
|
||||||
|
|
||||||
Future<void> _intervallSetzen(int stunden) async {
|
/// Setzt den Sync-Modus und persistiert ihn.
|
||||||
setState(() => _syncIntervall = stunden);
|
/// Startet/stoppt den Realtime-Service bzw. den Timer entsprechend.
|
||||||
|
Future<void> _syncModusSetzen(String modus) async {
|
||||||
|
setState(() => _syncModus = modus);
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
|
await p.setString('sync_modus', modus);
|
||||||
|
|
||||||
|
// Realtime-Service entsprechend starten/stoppen
|
||||||
|
switch (modus) {
|
||||||
|
case 'realtime':
|
||||||
|
// SSE sofort starten
|
||||||
|
final rs = RealtimeSyncService();
|
||||||
|
await rs.starteWennAktiviert();
|
||||||
|
break;
|
||||||
|
case 'manual':
|
||||||
|
// Kein Auto-Sync — Timer stoppen
|
||||||
|
await p.setInt('cloud_interval', 0); // alten Timer deaktivieren
|
||||||
|
await SyncService.starteAutoSyncTimer();
|
||||||
|
break;
|
||||||
|
case 'interval':
|
||||||
|
// Intervall-Timer mit cloud_interval_stunden starten
|
||||||
|
await p.setInt('cloud_interval', _cloudIntervallStunden);
|
||||||
|
await SyncService.starteAutoSyncTimer();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt das Cloud-Intervall (nur für Modus 3: interval).
|
||||||
|
Future<void> _intervallSetzen(int stunden) async {
|
||||||
|
setState(() => _cloudIntervallStunden = stunden);
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
await p.setInt('cloud_interval_stunden', stunden);
|
||||||
|
// Timer neu starten (der cloud_interval-Wert wird aktualisiert)
|
||||||
await p.setInt('cloud_interval', stunden);
|
await p.setInt('cloud_interval', stunden);
|
||||||
await p.setBool('cloud_auto', stunden > 0); // Kompatibilität (altes Flag)
|
await SyncService.starteAutoSyncTimer();
|
||||||
await SyncService.starteAutoSyncTimer(); // app-weiter Timer neu starten
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sleepDefaultSetzen(int min) async {
|
Future<void> _sleepDefaultSetzen(int min) async {
|
||||||
@@ -165,20 +223,51 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// ─── Sektion: Wiedergabe & Sync ───
|
// ─── Sektion: Cloud-Sync ───
|
||||||
_sektionHeader('Wiedergabe & Sync'),
|
_sektionHeader('Cloud-Sync'),
|
||||||
_einstellungsGruppe([
|
_einstellungsGruppe([
|
||||||
_gruppenZeile(
|
_gruppenZeile(
|
||||||
label: 'Auto-Sync-Intervall',
|
label: 'Sync-Modus',
|
||||||
wert: _syncIntervall == 0 ? 'Aus' : '${_syncIntervall}h',
|
wert: _syncModusText,
|
||||||
inhalt: Row(children: [
|
inhalt: Column(
|
||||||
_wahlChip('Aus', _syncIntervall == 0, () => _intervallSetzen(0)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_wahlChip('1h', _syncIntervall == 1, () => _intervallSetzen(1)),
|
children: [
|
||||||
_wahlChip('3h', _syncIntervall == 3, () => _intervallSetzen(3)),
|
_modusRadioTile('⚡ Echtzeit', 'realtime',
|
||||||
_wahlChip('6h', _syncIntervall == 6, () => _intervallSetzen(6)),
|
'Änderungen sofort synchronisieren (Push)'),
|
||||||
_wahlChip('12h', _syncIntervall == 12, () => _intervallSetzen(12)),
|
_modusRadioTile('👆 Manuell', 'manual',
|
||||||
]),
|
'Nur auf Knopfdruck synchronisieren'),
|
||||||
|
_modusRadioTile('🗓️ Zeit-Intervall', 'interval',
|
||||||
|
'Automatisch nach Zeitplan synchronisieren'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (_syncModus == 'interval') ...[
|
||||||
|
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||||||
|
_gruppenZeile(
|
||||||
|
label: 'Intervall',
|
||||||
|
wert: _intervallOptionen[_cloudIntervallStunden] ?? '${_cloudIntervallStunden}h',
|
||||||
|
inhalt: DropdownButtonFormField<int>(
|
||||||
|
initialValue: _intervallOptionen.containsKey(_cloudIntervallStunden)
|
||||||
|
? _cloudIntervallStunden
|
||||||
|
: 48,
|
||||||
|
dropdownColor: MeloTheme.dunkel1,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _intervallOptionen.entries.map((e) {
|
||||||
|
return DropdownMenuItem<int>(
|
||||||
|
value: e.key,
|
||||||
|
child: Text(e.value),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) _intervallSetzen(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const Divider(color: MeloTheme.dunkel2, height: 1),
|
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||||||
_gruppenZeile(
|
_gruppenZeile(
|
||||||
label: 'Sleep-Timer-Standard',
|
label: 'Sleep-Timer-Standard',
|
||||||
@@ -201,7 +290,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
max: 2.0,
|
max: 2.0,
|
||||||
divisions: 6,
|
divisions: 6,
|
||||||
activeColor: MeloTheme.akzent,
|
activeColor: MeloTheme.akzent,
|
||||||
// Live-Vorschau beim Ziehen, persistieren erst am Ende
|
|
||||||
onChanged: (v) => setState(() => _geschwindigkeit = v),
|
onChanged: (v) => setState(() => _geschwindigkeit = v),
|
||||||
onChangeEnd: _geschwindigkeitSetzen,
|
onChangeEnd: _geschwindigkeitSetzen,
|
||||||
),
|
),
|
||||||
@@ -556,4 +644,73 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anzeigetext für den aktuellen Sync-Modus.
|
||||||
|
String get _syncModusText {
|
||||||
|
switch (_syncModus) {
|
||||||
|
case 'realtime':
|
||||||
|
return '⚡ Echtzeit';
|
||||||
|
case 'manual':
|
||||||
|
return '👆 Manuell';
|
||||||
|
case 'interval':
|
||||||
|
return '🗓️ Zeit';
|
||||||
|
default:
|
||||||
|
return '⚡ Echtzeit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Radio-ähnliches Kachel-Widget für Sync-Modus-Auswahl.
|
||||||
|
Widget _modusRadioTile(String titel, String wert, String beschreibung) {
|
||||||
|
final ausgewaehlt = _syncModus == wert;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _syncModusSetzen(wert),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ausgewaehlt ? MeloTheme.akzent.withAlpha(25) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: ausgewaehlt ? MeloTheme.akzent : MeloTheme.dunkel2,
|
||||||
|
width: ausgewaehlt ? 1.5 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
ausgewaehlt ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||||
|
color: ausgewaehlt ? MeloTheme.akzent : MeloTheme.textSekundaer,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
titel,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ausgewaehlt ? Colors.white : MeloTheme.textSekundaer,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
beschreibung,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ausgewaehlt ? Colors.white70 : MeloTheme.textSekundaer,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
import '../database/db_helper.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/favoriten_service.dart';
|
||||||
|
import '../services/sync_service.dart';
|
||||||
|
import '../services/melo_logger.dart';
|
||||||
|
|
||||||
|
/// Sync-Modi für die Cloud-Synchronisation.
|
||||||
|
enum SyncModus {
|
||||||
|
echtzeit, // SSE-Push, sofort bei Änderungen
|
||||||
|
manuell, // Kein Auto-Sync, nur auf Knopfdruck
|
||||||
|
intervall, // Periodisches Polling (2T/3T/5T/1W/2W)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Echtzeit-Cloud-Sync via SSE (Server-Sent-Events).
|
||||||
|
///
|
||||||
|
/// Hält eine persistente HTTP-Verbindung zum Server offen und empfängt
|
||||||
|
/// Änderungen (Favoriten-Toggle, Song-Löschung, Titel-Update) in Echtzeit.
|
||||||
|
///
|
||||||
|
/// **3 Sync-Modi** (via SharedPreferences `sync_modus`):
|
||||||
|
/// 1. ⚡ **Echtzeit** (default): SSE-Push, sofortige Verarbeitung.
|
||||||
|
/// Bei Fehlern: Reconnect-Backoff (max 3 Versuche),
|
||||||
|
/// dann Fallback auf Intervall-Modus.
|
||||||
|
/// 2. 👆 **Manuell**: Kein Auto-Sync. Nur Button „Jetzt synchronisieren".
|
||||||
|
/// 3. 🗓️ **Intervall**: Periodisches Polling mit `cloud_interval_stunden`
|
||||||
|
/// (48/72/120/168/336 Stunden = 2T/3T/5T/1W/2W).
|
||||||
|
///
|
||||||
|
/// **Lifecycle** (via WidgetsBindingObserver in main.dart):
|
||||||
|
/// - resumed → fortsetzen() (SSE neu verbinden)
|
||||||
|
/// - paused/hidden → pausiere() (Batterie sparen)
|
||||||
|
class RealtimeSyncService {
|
||||||
|
RealtimeSyncService()
|
||||||
|
: _db = DbHelper(),
|
||||||
|
_auth = AuthService(),
|
||||||
|
_favoriten = FavoritenService();
|
||||||
|
|
||||||
|
final DbHelper _db;
|
||||||
|
final AuthService _auth;
|
||||||
|
final FavoritenService _favoriten;
|
||||||
|
|
||||||
|
// ─── Verbindungs-Zustand ───
|
||||||
|
|
||||||
|
StreamSubscription<String>? _sseSubscription;
|
||||||
|
HttpClientResponse? _sseResponse;
|
||||||
|
HttpClient? _httpClient;
|
||||||
|
bool _laeuft = false;
|
||||||
|
bool _pausiert = false;
|
||||||
|
|
||||||
|
/// Aktueller SSE-Event-Typ (wird über `event:`-Zeile gesetzt).
|
||||||
|
String? _aktuellerEventTyp;
|
||||||
|
|
||||||
|
// Reconnection-Backoff
|
||||||
|
int _reconnectVersuche = 0;
|
||||||
|
static const int _maxReconnectVersuche = 3;
|
||||||
|
static const Duration _backoffStart = Duration(seconds: 2);
|
||||||
|
|
||||||
|
// ─── Notifier für UI ───
|
||||||
|
|
||||||
|
final ValueNotifier<bool> istVerbundenNotifier = ValueNotifier(false);
|
||||||
|
final ValueNotifier<String?> fehlerNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
|
// ─── Konfiguration ───
|
||||||
|
|
||||||
|
/// Liest den aktuellen Sync-Modus aus SharedPreferences.
|
||||||
|
static Future<SyncModus> aktuellerModus() async {
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
final modus = p.getString('sync_modus') ?? 'realtime';
|
||||||
|
switch (modus) {
|
||||||
|
case 'manual':
|
||||||
|
return SyncModus.manuell;
|
||||||
|
case 'interval':
|
||||||
|
return SyncModus.intervall;
|
||||||
|
default:
|
||||||
|
return SyncModus.echtzeit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest das Cloud-Intervall in Stunden (für Modus 3).
|
||||||
|
/// Migriert alte `cloud_interval`-Werte (1/3/6/12h) auf
|
||||||
|
/// den neuen Default 48h (2 Tage).
|
||||||
|
static Future<int> cloudIntervallStunden() async {
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
// Neuer Key zuerst prüfen
|
||||||
|
final neu = p.getInt('cloud_interval_stunden');
|
||||||
|
if (neu != null && neu > 0) return neu;
|
||||||
|
|
||||||
|
// Migration vom alten Key
|
||||||
|
final alt = p.getInt('cloud_interval') ?? 0;
|
||||||
|
if (alt == 1 || alt == 3 || alt == 6 || alt == 12) {
|
||||||
|
// Alte Stunden-Werte → auf neuen Default migrieren
|
||||||
|
await p.setInt('cloud_interval_stunden', 48);
|
||||||
|
MeloLogger().aktion('cloud_interval_migriert', {'alt': alt, 'neu': 48});
|
||||||
|
return 48;
|
||||||
|
}
|
||||||
|
// Default: 48h (2 Tage)
|
||||||
|
await p.setInt('cloud_interval_stunden', 48);
|
||||||
|
return 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Öffentliche API ───
|
||||||
|
|
||||||
|
/// Startet den Realtime-Sync, sofern der Modus 'realtime' aktiv ist
|
||||||
|
/// und der Benutzer angemeldet ist.
|
||||||
|
Future<void> starteWennAktiviert() async {
|
||||||
|
final modus = await aktuellerModus();
|
||||||
|
final user = _auth.benutzer;
|
||||||
|
|
||||||
|
if (modus != SyncModus.echtzeit || user.isEmpty) {
|
||||||
|
stoppe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe, ob der Server SSE unterstützt (HEAD auf /subscribe).
|
||||||
|
// 404 = kein SSE-Endpoint → Fallback auf Intervall.
|
||||||
|
final kannSse = await _pruefeSseUnterstuetzung();
|
||||||
|
if (!kannSse) {
|
||||||
|
MeloLogger().aktion('realtime_sse_nicht_verfuegbar', {});
|
||||||
|
// Fallback: sync_modus auf interval setzen + Auto-Sync-Timer starten
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
await p.setString('sync_modus', 'interval');
|
||||||
|
await SyncService.starteAutoSyncTimer();
|
||||||
|
stoppe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _verbinde();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft via HEAD-Request, ob der SSE-Endpoint existiert.
|
||||||
|
Future<bool> _pruefeSseUnterstuetzung() async {
|
||||||
|
try {
|
||||||
|
final token = _auth.token;
|
||||||
|
if (token == null || token.isEmpty) return false;
|
||||||
|
|
||||||
|
final uri = Uri.parse('${AppConfig.cloudUrl}/api/v1/cloud/subscribe');
|
||||||
|
final client = HttpClient();
|
||||||
|
client.connectionTimeout = const Duration(seconds: 5);
|
||||||
|
try {
|
||||||
|
final request = await client.openUrl('HEAD', uri);
|
||||||
|
request.headers.add('Authorization', 'Bearer $token');
|
||||||
|
final response = await request.close();
|
||||||
|
final ok = response.statusCode != 404;
|
||||||
|
client.close();
|
||||||
|
return ok;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stellt die SSE-Verbindung her und beginnt den Event-Stream.
|
||||||
|
Future<void> _verbinde() async {
|
||||||
|
if (_laeuft || _pausiert) return;
|
||||||
|
_laeuft = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final token = _auth.token;
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
_fehlerBericht('Kein Auth-Token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_httpClient?.close();
|
||||||
|
_httpClient = HttpClient();
|
||||||
|
_httpClient!.connectionTimeout = const Duration(seconds: 30);
|
||||||
|
|
||||||
|
final uri = Uri.parse('${AppConfig.cloudUrl}/api/v1/cloud/subscribe');
|
||||||
|
final request = await _httpClient!.getUrl(uri);
|
||||||
|
request.headers.add('Authorization', 'Bearer $token');
|
||||||
|
request.headers.add('Accept', 'text/event-stream');
|
||||||
|
request.headers.add('Cache-Control', 'no-cache');
|
||||||
|
|
||||||
|
_sseResponse = await request.close();
|
||||||
|
|
||||||
|
if (_sseResponse!.statusCode != 200) {
|
||||||
|
_fehlerBericht('HTTP ${_sseResponse!.statusCode}');
|
||||||
|
await _reconnecteOderFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconnectVersuche = 0;
|
||||||
|
istVerbundenNotifier.value = true;
|
||||||
|
fehlerNotifier.value = null;
|
||||||
|
|
||||||
|
MeloLogger().aktion('realtime_verbunden', {});
|
||||||
|
|
||||||
|
// Stream in UTF-8-Zeilen parsen
|
||||||
|
_sseSubscription = _sseResponse!
|
||||||
|
.transform(utf8.decoder)
|
||||||
|
.transform(const LineSplitter())
|
||||||
|
.listen(
|
||||||
|
verarbeiteSseZeile,
|
||||||
|
onError: (e) {
|
||||||
|
_fehlerBericht('Stream-Fehler: $e');
|
||||||
|
_reconnecteOderFallback();
|
||||||
|
},
|
||||||
|
onDone: () => _reconnecteOderFallback(),
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_fehlerBericht('Verbindungsfehler: $e');
|
||||||
|
await _reconnecteOderFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSE-Zeilen-Parser: akkumuliert `event:`- und `data:`-Zeilen
|
||||||
|
/// und dispatched bei Leerzeile.
|
||||||
|
@visibleForTesting
|
||||||
|
void verarbeiteSseZeile(String zeile) {
|
||||||
|
if (zeile.isEmpty) {
|
||||||
|
// Leerzeile = Event-Ende → dispatch
|
||||||
|
_aktuelleEventVerarbeiten();
|
||||||
|
_aktuellerEventTyp = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zeile.startsWith('event: ')) {
|
||||||
|
_aktuellerEventTyp = zeile.substring(7).trim();
|
||||||
|
} else if (zeile.startsWith('data: ')) {
|
||||||
|
final datenStr = zeile.substring(6).trim();
|
||||||
|
if (datenStr.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final daten = jsonDecode(datenStr) as Map<String, dynamic>;
|
||||||
|
_verarbeiteSseEvent(_aktuellerEventTyp, daten);
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('realtime_parse', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Andere Zeilen (id:, retry:, Kommentare) ignorieren
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch nach Leerzeile (falls unverarbeitete Daten übrig).
|
||||||
|
void _aktuelleEventVerarbeiten() {
|
||||||
|
// Keine Aktion nötig — data:-Zeilen werden sofort verarbeitet,
|
||||||
|
// da sie direkt auf eine event:-Zeile folgen.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verarbeitet ein einzelnes SSE-Event.
|
||||||
|
void _verarbeiteSseEvent(String? eventTyp, Map<String, dynamic> daten) {
|
||||||
|
// Heartbeat: ignorieren (hält nur die Verbindung offen)
|
||||||
|
if (eventTyp == 'heartbeat') return;
|
||||||
|
|
||||||
|
MeloLogger().aktion('realtime_event', {
|
||||||
|
'type': eventTyp,
|
||||||
|
'data': daten,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (eventTyp) {
|
||||||
|
case 'song_favorite':
|
||||||
|
_handleFavoritEvent(daten);
|
||||||
|
break;
|
||||||
|
case 'song_delete':
|
||||||
|
_handleDeleteEvent(daten);
|
||||||
|
break;
|
||||||
|
case 'song_update':
|
||||||
|
_handleUpdateEvent(daten);
|
||||||
|
break;
|
||||||
|
case 'playlist_update':
|
||||||
|
// Ignorieren — Playlisten werden beim nächsten vollständigen
|
||||||
|
// Sync aktualisiert (zu bandwidth-intensiv für Echtzeit).
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Song-Favorit-Event: Server hat einen Favoriten-Status geändert.
|
||||||
|
Future<void> _handleFavoritEvent(Map<String, dynamic> daten) async {
|
||||||
|
final songId = daten['song_id'] as String?;
|
||||||
|
final isFavorite = daten['is_favorite'] as bool? ?? false;
|
||||||
|
|
||||||
|
if (songId == null || songId.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final song = await _db.songNachCloudId(songId);
|
||||||
|
if (song == null || song.id == null) {
|
||||||
|
// Song nicht lokal → beim nächsten Sync nachziehen
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final istLokalFavorit = await _favoriten.istFavorit(song.id!);
|
||||||
|
|
||||||
|
// Nur toggeln, wenn der lokale Zustand vom Server abweicht
|
||||||
|
if (isFavorite != istLokalFavorit) {
|
||||||
|
await _favoriten.umschalten(song.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
MeloLogger().aktion('realtime_favorit_angewendet', {
|
||||||
|
'songId': songId,
|
||||||
|
'isFavorite': isFavorite,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('realtime_favorit_error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Song-Lösch-Event: Server hat einen Song gelöscht (Tombstone).
|
||||||
|
Future<void> _handleDeleteEvent(Map<String, dynamic> daten) async {
|
||||||
|
final songId = daten['song_id'] as String?;
|
||||||
|
final deletedAt = daten['deleted_at'] as String?;
|
||||||
|
|
||||||
|
if (songId == null || songId.isEmpty) return;
|
||||||
|
|
||||||
|
// Tombstone-Check: nur löschen, wenn die Server-Löschung
|
||||||
|
// neuer als der letzte lokale Sync ist.
|
||||||
|
final letzterSync = await _letzterSync();
|
||||||
|
final geloeschtAm = deletedAt != null ? DateTime.tryParse(deletedAt) : null;
|
||||||
|
// Alt-Tombstone (kein Zeitstempel) oder Erst-Sync → anwenden
|
||||||
|
if (letzterSync != null && geloeschtAm != null && !geloeschtAm.isAfter(letzterSync)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final song = await _db.songNachCloudId(songId);
|
||||||
|
if (song == null || song.id == null) return;
|
||||||
|
|
||||||
|
// Datei löschen (falls lokal vorhanden)
|
||||||
|
try {
|
||||||
|
final datei = File(song.dateiPfad);
|
||||||
|
if (song.dateiPfad.isNotEmpty && await datei.exists()) {
|
||||||
|
await datei.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Datei-Fehler dürfen den Sync nicht abbrechen
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.loeschSong(song.id!);
|
||||||
|
MeloLogger().aktion('realtime_song_geloescht', {'songId': songId});
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('realtime_delete_error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Song-Update-Event: Titel oder Künstler wurden auf dem Server geändert.
|
||||||
|
Future<void> _handleUpdateEvent(Map<String, dynamic> daten) async {
|
||||||
|
final songId = daten['song_id'] as String?;
|
||||||
|
final title = daten['title'] as String?;
|
||||||
|
final artist = daten['artist'] as String?;
|
||||||
|
|
||||||
|
if (songId == null || songId.isEmpty) return;
|
||||||
|
if (title == null && artist == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final song = await _db.songNachCloudId(songId);
|
||||||
|
if (song == null || song.id == null) return;
|
||||||
|
|
||||||
|
// Metadaten lokal aktualisieren
|
||||||
|
await _db.metadatenAktualisieren(
|
||||||
|
song.id!,
|
||||||
|
titel: title,
|
||||||
|
kuenstler: artist,
|
||||||
|
);
|
||||||
|
|
||||||
|
MeloLogger().aktion('realtime_update', {
|
||||||
|
'songId': songId,
|
||||||
|
'title': title,
|
||||||
|
'artist': artist,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('realtime_update_error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconnect mit exponentiellem Backoff: 2s → 4s → 8s.
|
||||||
|
/// Nach [maxReconnectVersuche] Fehlversuchen: Fallback auf Intervall-Modus.
|
||||||
|
Future<void> _reconnecteOderFallback() async {
|
||||||
|
istVerbundenNotifier.value = false;
|
||||||
|
_laeuft = false;
|
||||||
|
|
||||||
|
_reconnectVersuche++;
|
||||||
|
|
||||||
|
if (_reconnectVersuche <= _maxReconnectVersuche) {
|
||||||
|
final backoff = backoffBerechnen(_reconnectVersuche);
|
||||||
|
MeloLogger().aktion('realtime_reconnect', {
|
||||||
|
'attempt': _reconnectVersuche,
|
||||||
|
'delaySeconds': backoff.inSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.delayed(backoff);
|
||||||
|
if (!_pausiert) {
|
||||||
|
await _verbinde();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Max Reconnects überschritten → Fallback auf Intervall-Modus
|
||||||
|
_fehlerBericht('Max Reconnects erreicht — Fallback auf Intervall');
|
||||||
|
MeloLogger().aktion('realtime_fallback_to_interval', {});
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
await p.setString('sync_modus', 'interval');
|
||||||
|
await SyncService.starteAutoSyncTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Berechnet das Backoff-Delay: 2^attempt * 2 Sekunden.
|
||||||
|
/// Clamped auf max 8 Sekunden. Versuch <1 → 0 Sekunden (kein Shift-Fehler).
|
||||||
|
@visibleForTesting
|
||||||
|
Duration backoffBerechnen(int versuch) {
|
||||||
|
if (versuch < 1) return Duration.zero;
|
||||||
|
final sekunden = (_backoffStart.inSeconds * (1 << (versuch - 1))).clamp(0, 8);
|
||||||
|
return Duration(seconds: sekunden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest den letzten Sync-Zeitstempel aus SharedPreferences.
|
||||||
|
Future<DateTime?> _letzterSync() async {
|
||||||
|
final p = await SharedPreferences.getInstance();
|
||||||
|
final ts = p.getString('cloud_last_sync_ts');
|
||||||
|
return ts != null ? DateTime.tryParse(ts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fehlerBericht(String nachricht) {
|
||||||
|
MeloLogger().fehler('realtime_error', Exception(nachricht));
|
||||||
|
fehlerNotifier.value = nachricht;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pausiert die SSE-Verbindung (App im Hintergrund → Batterie sparen).
|
||||||
|
void pausiere() {
|
||||||
|
if (_pausiert) return;
|
||||||
|
_pausiert = true;
|
||||||
|
_sseSubscription?.cancel();
|
||||||
|
_sseSubscription = null;
|
||||||
|
_sseResponse = null;
|
||||||
|
_httpClient?.close(force: true);
|
||||||
|
_httpClient = null;
|
||||||
|
_laeuft = false;
|
||||||
|
istVerbundenNotifier.value = false;
|
||||||
|
MeloLogger().aktion('realtime_pausiert', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt die SSE-Verbindung nach einer Pause fort.
|
||||||
|
Future<void> fortsetzen() async {
|
||||||
|
if (!_pausiert) return;
|
||||||
|
_pausiert = false;
|
||||||
|
await starteWennAktiviert();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stoppt die SSE-Verbindung endgültig (z. B. Logout, Modus-Wechsel).
|
||||||
|
void stoppe() {
|
||||||
|
_sseSubscription?.cancel();
|
||||||
|
_sseSubscription = null;
|
||||||
|
_sseResponse = null;
|
||||||
|
_httpClient?.close(force: true);
|
||||||
|
_httpClient = null;
|
||||||
|
_laeuft = false;
|
||||||
|
_pausiert = false;
|
||||||
|
istVerbundenNotifier.value = false;
|
||||||
|
fehlerNotifier.value = null;
|
||||||
|
MeloLogger().aktion('realtime_gestoppt', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo_app/services/realtime_sync_service.dart';
|
||||||
|
import 'package:melo_app/services/sync_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// RealtimeSyncService-Tests (v2.55 – Echtzeit-Cloud-Sync).
|
||||||
|
///
|
||||||
|
/// Abgedeckt:
|
||||||
|
/// - Backoff-Berechnung (exponentiell, clamped auf max 8s)
|
||||||
|
/// - SSE-Event-Parsing (heartbeat ignoriert, song_favorite verarbeitet)
|
||||||
|
/// - Tombstone-Respektierung bei Song-Löschung
|
||||||
|
/// - Leere song_id wird ignoriert
|
||||||
|
/// - Sync-Modus-Prüfung (manual/interval/realtime Enum-Werte)
|
||||||
|
///
|
||||||
|
/// KEINE neuen Packages — reine Unit-Tests mit einfachen Hand-Mocks.
|
||||||
|
/// Verwendet nur flutter_test + bestehende Projekt-Abhängigkeiten.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
// ── Gruppe 1: Backoff-Berechnung ──
|
||||||
|
|
||||||
|
group('Backoff-Berechnung (exponentiell)', () {
|
||||||
|
test('Versuch 1 → 2 Sekunden', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
final backoff = service.backoffBerechnen(1);
|
||||||
|
expect(backoff.inSeconds, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Versuch 2 → 4 Sekunden', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
final backoff = service.backoffBerechnen(2);
|
||||||
|
expect(backoff.inSeconds, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Versuch 3 → 8 Sekunden', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
final backoff = service.backoffBerechnen(3);
|
||||||
|
expect(backoff.inSeconds, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Versuch 4+ → clamped auf max 8 Sekunden', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
expect(service.backoffBerechnen(4).inSeconds, 8);
|
||||||
|
expect(service.backoffBerechnen(5).inSeconds, 8);
|
||||||
|
expect(service.backoffBerechnen(10).inSeconds, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Versuch 0 → 0 Sekunden (Clamp)', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
final backoff = service.backoffBerechnen(0);
|
||||||
|
expect(backoff.inSeconds, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Gruppe 2: SSE-Event-Parsing ──
|
||||||
|
|
||||||
|
group('SSE-Event-Parsing', () {
|
||||||
|
test('Heartbeat wird ignoriert (event: heartbeat)', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
// Heartbeat-Event → sollte keinen Fehler werfen
|
||||||
|
service.verarbeiteSseZeile('event: heartbeat');
|
||||||
|
service.verarbeiteSseZeile('data: {}');
|
||||||
|
// Kein Fehler → Test bestanden
|
||||||
|
expect(service.fehlerNotifier.value, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat data:-Zeile wird still verarbeitet', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
// Nach einem Heartbeat-Event darf keine Exception fliegen
|
||||||
|
service.verarbeiteSseZeile('event: heartbeat');
|
||||||
|
expect(() => service.verarbeiteSseZeile('data: {}'), returnsNormally);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song_favorite Event wird erkannt (kein Parse-Fehler)', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
// Gültiges song_favorite-Event — darf keinen Parse-Fehler werfen
|
||||||
|
service.verarbeiteSseZeile('event: song_favorite');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile(
|
||||||
|
'data: {"song_id":"abc123","is_favorite":true}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song_delete mit leerer song_id verarbeitet still', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
// Leere song_id → Event wird ignoriert (kein Crash)
|
||||||
|
service.verarbeiteSseZeile('event: song_delete');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile(
|
||||||
|
'data: {"song_id":"","deleted_at":"2026-08-01T12:00:00"}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song_update Event wird erkannt (kein Parse-Fehler)', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.verarbeiteSseZeile('event: song_update');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile(
|
||||||
|
'data: {"song_id":"abc123","title":"Neuer Titel","artist":"Künstler"}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlist_update wird ignoriert (kein Fehler)', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.verarbeiteSseZeile('event: playlist_update');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile(
|
||||||
|
'data: {"playlist_id":42}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Gruppe 3: Tombstone-Respektierung ──
|
||||||
|
|
||||||
|
group('Tombstone-Respektierung bei Song-Löschung', () {
|
||||||
|
final letzterSync = DateTime(2026, 8, 4, 12, 0, 0);
|
||||||
|
|
||||||
|
test('deletedAt neuer als letzterSync → anwenden', () {
|
||||||
|
expect(
|
||||||
|
SyncService.tombstoneAnwenden(
|
||||||
|
letzterSync: letzterSync,
|
||||||
|
deletedAt: '2026-08-05T10:00:00'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletedAt älter als letzterSync → NICHT anwenden', () {
|
||||||
|
expect(
|
||||||
|
SyncService.tombstoneAnwenden(
|
||||||
|
letzterSync: letzterSync,
|
||||||
|
deletedAt: '2026-08-03T10:00:00'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletedAt null (Alt-Tombstone ohne Zeitstempel) → anwenden', () {
|
||||||
|
expect(
|
||||||
|
SyncService.tombstoneAnwenden(
|
||||||
|
letzterSync: letzterSync, deletedAt: null),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('letzterSync null (Erst-Sync) → anwenden', () {
|
||||||
|
expect(
|
||||||
|
SyncService.tombstoneAnwenden(
|
||||||
|
letzterSync: null, deletedAt: '2026-08-05T10:00:00'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletedAt == letzterSync (Gleichstand) → NICHT anwenden', () {
|
||||||
|
expect(
|
||||||
|
SyncService.tombstoneAnwenden(
|
||||||
|
letzterSync: letzterSync,
|
||||||
|
deletedAt: '2026-08-04T12:00:00'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ungültiger deletedAt-String → anwenden (sicherer Default)', () {
|
||||||
|
expect(
|
||||||
|
SyncService.tombstoneAnwenden(
|
||||||
|
letzterSync: letzterSync, deletedAt: 'kein-iso-timestamp'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Gruppe 4: Leere song_id ──
|
||||||
|
|
||||||
|
group('Leere song_id wird ignoriert', () {
|
||||||
|
test('song_favorite mit leerer song_id → kein Fehler', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.verarbeiteSseZeile('event: song_favorite');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile(
|
||||||
|
'data: {"song_id":"","is_favorite":true}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song_delete mit null song_id → kein Fehler', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.verarbeiteSseZeile('event: song_delete');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile('data: {"deleted_at":"2026-08-01T12:00:00"}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song_update ohne song_id → kein Fehler', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.verarbeiteSseZeile('event: song_update');
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile(
|
||||||
|
'data: {"title":"Nur Titel"}'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ungültiges JSON in data:-Zeile → kein Crash', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile('data: kein-json'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Leere data:-Zeile → kein Fehler', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
expect(
|
||||||
|
() => service.verarbeiteSseZeile('data: '),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Gruppe 5: Sync-Modus-Prüfung ──
|
||||||
|
|
||||||
|
group('Sync-Modus (Enum + Werte)', () {
|
||||||
|
test('SyncModus hat genau 3 Werte', () {
|
||||||
|
const alle = SyncModus.values;
|
||||||
|
expect(alle.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SyncModus-Werte sind eindeutig', () {
|
||||||
|
const alle = SyncModus.values;
|
||||||
|
expect(alle.toSet().length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aktuellerModus() liefert immer einen gültigen Modus', () async {
|
||||||
|
final modus = await RealtimeSyncService.aktuellerModus();
|
||||||
|
// Default ist 'realtime' (SharedPreferences ohne Mock = leer)
|
||||||
|
expect(modus, SyncModus.echtzeit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cloudIntervallStunden() liefert Default 48h', () async {
|
||||||
|
final intervall = await RealtimeSyncService.cloudIntervallStunden();
|
||||||
|
// Ohne gespeicherte Werte = Default 48 (SharedPreferences leer)
|
||||||
|
expect(intervall, greaterThanOrEqualTo(48));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verschiedene Modi sind unterscheidbar', () {
|
||||||
|
expect(SyncModus.manuell, isNot(SyncModus.echtzeit));
|
||||||
|
expect(SyncModus.manuell, isNot(SyncModus.intervall));
|
||||||
|
expect(SyncModus.echtzeit, isNot(SyncModus.intervall));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Gruppe 6: Notifier-Verhalten ──
|
||||||
|
|
||||||
|
group('Notifier-Verhalten', () {
|
||||||
|
test('istVerbundenNotifier startet mit false', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
expect(service.istVerbundenNotifier.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlerNotifier startet mit null', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
expect(service.fehlerNotifier.value, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stoppe() setzt istVerbunden auf false', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.istVerbundenNotifier.value = true;
|
||||||
|
service.stoppe();
|
||||||
|
expect(service.istVerbundenNotifier.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pausiere() setzt istVerbunden auf false', () {
|
||||||
|
final service = RealtimeSyncService();
|
||||||
|
service.istVerbundenNotifier.value = true;
|
||||||
|
service.pausiere();
|
||||||
|
expect(service.istVerbundenNotifier.value, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user