From 77772970735577f1904eb32d3c57f1939059ccd3 Mon Sep 17 00:00:00 2001 From: Dustin Date: Thu, 6 Aug 2026 18:11:41 +0200 Subject: [PATCH] =?UTF-8?q?v2.55.2=20=E2=80=94=20Manueller=20Sync:=20Statu?= =?UTF-8?q?s-Popup,=20Hintergrund-Weiterlauf,=20Sperrbildschirm-Notificati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cloud_screen.dart: Sync-Popup mit Live-Fortschritt („12/325 synchronisiert"), „Im Hintergrund fortsetzen"-Button, Ergebnis-Snackbar nach Abschluss - sync_service.dart: Notification-Kanal auf HIGH + Visibility.public, Foreground-Service starten/stoppen während Sync (Display-aus-Garantie), statische syncNotificationDetails() für Tests - main.dart: Sync-Channel-Importance auf HIGH (überschreibt alte low-Einstellung) - Tests: +7 (syncNotificationDetails HIGH/public, Lifecycle-Unabhängigkeit) --- lib/main.dart | 5 +- lib/screens/cloud_screen.dart | 160 +++++++++++++++++++++++++++++++++ lib/services/sync_service.dart | 106 ++++++++++++++++------ test/sync_service_test.dart | 84 ++++++++++++++++- 4 files changed, 323 insertions(+), 32 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 5da0e7a..4be62d9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,11 +60,14 @@ void main() async { ?.createNotificationChannel(downloadChannel); // Notification-Channel für den Cloud-Sync (Fortschritt + Abschluss) + // v2.55.2: Importance HIGH + Visibility public für Sperrbildschirm + Shadow. + // createNotificationChannel überschreibt bestehende Channel-Settings — + // ein alter Kanal mit low-Importance wird beim nächsten App-Start korrigiert. const syncChannel = AndroidNotificationChannel( 'de.baka.melo.sync', 'Melo Sync', description: 'Cloud-Sync-Fortschritt und Abschluss', - importance: Importance.low, + importance: Importance.high, playSound: false, enableVibration: false, ); diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index 6c7b7f1..79092d3 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -56,6 +56,10 @@ class _CloudScreenState extends State int _syncedItems = 0; int _syncGesamt = 0; + /// v2.55.2: BuildContext des aktiven Sync-Popups, damit der Dialog + /// aus _syncEnde() heraus geschlossen werden kann. + BuildContext? _syncPopupContext; + /// Zentraler Sync-Loop (F3: läuft auch ohne geöffneten Tab weiter, /// persistente Notification + Abschluss-Benachrichtigung + Chip-Puls). late final SyncService _sync; @@ -230,6 +234,10 @@ class _CloudScreenState extends State /// Komplett-Sync (F3): delegiert an den zentralen [SyncService], der auch /// ohne geöffneten Cloud-Tab weiterläuft (persistente Notification, /// Abschluss-Benachrichtigung, ☁️-Chip-Puls, globaler Doppel-Sync-Guard). + /// + /// v2.55.2: Zeigt ein Popup mit Live-Fortschritt („12/325 synchronisiert“), + /// das per [Im Hintergrund fortsetzen] geschlossen werden kann — der Sync + /// läuft dann im Hintergrund weiter (Foreground-Service + Notification). Future _syncAlles({bool automatisch = false}) async { if (SyncService.laeuftGlobal) return false; if (mounted) { @@ -242,12 +250,134 @@ class _CloudScreenState extends State }); } _syncAnimController.repeat(); + + // v2.55.2: Popup mit Live-Fortschritt anzeigen (nur bei manuellem Sync). + // Der Dialog läuft mittels StatefulBuilder und schließt sich automatisch, + // wenn der Sync beendet ist, oder per „Im Hintergrund fortsetzen“- + // Button (dann läuft der Sync im Service weiter). + if (!automatisch && mounted) { + _zeigeSyncPopup(); + } + return _sync.syncAlles(automatisch: automatisch); } + /// v2.55.2: Popup-Dialog mit Live-Sync-Fortschritt. + /// Läuft parallel zum SyncService-Loop — die Fortschritts-Callbacks + /// (_syncedItems/_syncGesamt) werden via setState im Dialog aktualisiert. + /// Der Dialog hat zwei Ausgänge: + /// 1. Sync beendet → Dialog schließt automatisch + Ergebnis anzeigen + /// 2. „Im Hintergrund fortsetzen“ → Dialog schließt, Sync läuft weiter + void _zeigeSyncPopup() { + _syncPopupContext = null; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + _syncPopupContext = ctx; + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: MeloTheme.rot, + strokeWidth: 2, + ), + ), + const SizedBox(width: 12), + const Text('Sync läuft', + style: TextStyle(color: Colors.white, fontSize: 17)), + ], + ), + content: SizedBox( + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Fortschrittsbalken + if (_syncGesamt > 0) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: _syncGesamt > 0 + ? (_syncedItems / _syncGesamt).clamp(0.0, 1.0) + : null, + backgroundColor: MeloTheme.dunkel2, + color: MeloTheme.rot, + minHeight: 8, + ), + ), + const SizedBox(height: 12), + ], + // Fortschritts-Text (z. B. „12/325 synchronisiert“) + Text( + _syncGesamt > 0 + ? '$_syncedItems / $_syncGesamt synchronisiert' + : _syncPhase.isNotEmpty + ? _syncPhase + : 'Synchronisiere…', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600), + ), + if (_syncPhase.isNotEmpty && _syncGesamt > 0) ...[ + const SizedBox(height: 4), + Text( + _syncPhase, + style: const TextStyle( + color: MeloTheme.textSekundaer, fontSize: 12), + ), + ], + const SizedBox(height: 16), + // Button: Im Hintergrund fortsetzen + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: () { + _syncPopupContext = null; + Navigator.pop(ctx); + }, + icon: const Icon(Icons.phone_android, + color: MeloTheme.textSekundaer, size: 18), + label: const Text('Im Hintergrund fortsetzen', + style: TextStyle( + color: MeloTheme.textSekundaer, + fontSize: 13)), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide( + color: MeloTheme.dunkel2, width: 1), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ).then((_) { + _syncPopupContext = null; + }); + } + /// Immer am Loop-Ende (auch bei Fehler / nach „Im Hintergrund fortsetzen“): /// Animation stoppen, Sync-Ansicht schließen. Controller-Zugriffe abgesichert, /// falls der Screen während des Hintergrund-Syncs disposed wurde (MED-4). + /// + /// v2.55.2: Schließt das Sync-Popup (falls noch offen) und zeigt das + /// Ergebnis als Snackbar an. void _syncEnde() { try { _syncAnimController.stop(); @@ -255,11 +385,41 @@ class _CloudScreenState extends State } catch (_) { // Controller kann bereits disposed sein (Screen verlassen) } + + // v2.55.2: Sync-Popup schließen (falls noch offen) + final popupCtx = _syncPopupContext; + if (popupCtx != null && mounted) { + try { + Navigator.of(popupCtx).pop(); + } catch (_) { + // Dialog wurde bereits geschlossen (z. B. „Im Hintergrund fortsetzen“) + } + _syncPopupContext = null; + } + if (mounted) { setState(() { _syncLaeuft = false; _syncPhase = ''; }); + + // v2.55.2: Ergebnis-Snackbar anzeigen (nur wenn Screen noch sichtbar) + if (_status != null && _status!.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_status!, + style: const TextStyle(color: Colors.white, fontSize: 14)), + backgroundColor: _statusOk ? MeloTheme.dunkel2 : const Color(0xFF3B0D0D), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 4), + action: SnackBarAction( + label: 'OK', + textColor: MeloTheme.rot, + onPressed: () {}, + ), + ), + ); + } } } diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 07d49b2..1e5bcb9 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -4,6 +4,7 @@ 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 'package:flutter_background_service/flutter_background_service.dart'; import '../database/db_helper.dart'; import '../models/song.dart'; import '../utils/sanitize.dart'; @@ -253,11 +254,19 @@ class SyncService { /// 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). + /// + /// v2.55.2: Startet den Foreground-Service (FlutterBackgroundService), + /// damit der Sync auch bei Display-aus und minimierter App weiterläuft. Future syncAlles({bool automatisch = false}) async { if (_laeuftGlobal) return false; _laeuftGlobal = true; laeuftNotifier.value = true; String? konfliktBatch; + + // v2.55.2: Foreground-Service starten — hält den Prozess im Hintergrund + // aktiv, auch wenn das Display aus ist oder die App minimiert wird. + await _starteForegroundService(); + try { _zeigeSyncNotification('Verbinde…', 0, 0); onFortschritt?.call('Verbinde...', 0); @@ -527,11 +536,41 @@ class SyncService { } catch (_) { // Plugin kann beim App-Exit bereits disposed sein } + // v2.55.2: Foreground-Service stoppen, sobald der Sync beendet ist. + await _stoppeForegroundService(); onSyncEnde?.call(); } } - // ─── Notifications ─── + // ─── Notifications (v2.55.2: HIGH + public für Sperrbildschirm) ─── + + /// Erzeugt die [AndroidNotificationDetails] für Sync-Notifications. + /// Statisch → testbar ohne Platform-/Plugin-Zugriff. + /// [ongoing]=true für Fortschritts-Notification (nicht wegwischbar), + /// [ongoing]=false für Abschluss-/Fehler-Notification (automatisch gelöscht). + @visibleForTesting + static AndroidNotificationDetails syncNotificationDetails({ + required bool ongoing, + int progress = 0, + int maxProgress = 0, + }) { + return AndroidNotificationDetails( + _syncChannelId, + 'Melo Sync', + channelDescription: ongoing + ? 'Cloud-Sync-Fortschritt' + : 'Cloud-Sync-Abschluss', + importance: Importance.high, + priority: Priority.high, + visibility: NotificationVisibility.public, + onlyAlertOnce: true, + showProgress: ongoing, + maxProgress: ongoing ? (maxProgress > 0 ? maxProgress : 1) : 0, + progress: ongoing ? progress : 0, + ongoing: ongoing, + autoCancel: !ongoing, + ); + } /// Persistente Fortschritts-Notification (nicht wegwischbar, ongoing). void _zeigeSyncNotification(String body, int aktuell, int gesamt) { @@ -543,18 +582,10 @@ class SyncService { 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, + android: syncNotificationDetails( ongoing: true, - autoCancel: false, + progress: p, + maxProgress: maxP, ), ), ); @@ -573,14 +604,7 @@ class SyncService { 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, - ), + android: syncNotificationDetails(ongoing: false), ), ); } @@ -597,18 +621,44 @@ class SyncService { 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, - ), + android: syncNotificationDetails(ongoing: false), ), ); } + // ─── Foreground-Service (v2.55.2) ─── + + /// Startet den Foreground-Service, damit der Sync auch bei Display-aus + /// und minimierter App weiterläuft (Android-Prozess-Garantie). + Future _starteForegroundService() async { + if (!Platform.isAndroid) return; + try { + final svc = FlutterBackgroundService(); + final laeuft = await svc.isRunning(); + if (!laeuft) { + await svc.startService(); + MeloLogger().aktion('foreground_sync_start', {}); + } + } catch (e) { + MeloLogger().fehler('foreground_sync_start', e); + } + } + + /// Stoppt den Foreground-Service nach Sync-Ende. + Future _stoppeForegroundService() async { + if (!Platform.isAndroid) return; + try { + final svc = FlutterBackgroundService(); + final laeuft = await svc.isRunning(); + if (laeuft) { + svc.invoke('stopService'); // Feuer-und-vergessen (Isolate-Nachricht) + MeloLogger().aktion('foreground_sync_stop', {}); + } + } catch (e) { + MeloLogger().fehler('foreground_sync_stop', e); + } + } + String _formatZeit(DateTime? dt) { if (dt == null) return 'Nie'; return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart index 13ca243..8ba8025 100644 --- a/test/sync_service_test.dart +++ b/test/sync_service_test.dart @@ -1,7 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:melo_app/services/sync_service.dart'; +import 'package:melo_app/services/realtime_sync_service.dart'; -/// SyncService-Tests (Sprint E, MED-3-Review-Fix). +/// SyncService-Tests (Sprint E, MED-3-Review-Fix, v2.55.2). /// /// Abgedeckt: /// - `tombstoneAnwenden`-Matrix (null-letzterSync / null-deletedAt / @@ -9,8 +11,10 @@ import 'package:melo_app/services/sync_service.dart'; /// - `SyncBericht` (hatAenderungen, zusammenfassung, details) /// - `songAusServerMap` (MED-2: Server-Listeneintrag → lokaler Song-DB- /// Eintrag mit cloud_id + Titeldaten) -/// -/// Reine, statische Funktionen — kein DB-/Plattform-Zugriff nötig. +/// - v2.55.2: `syncNotificationDetails` (Importance.high, +/// Priority.high, Visibility.public für Sperrbildschirm) +/// - v2.55.2: Sync-Service von Lifecycle unabhängig +/// (RealtimeSyncService.pausiere() ≠ SyncService stoppen) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -174,4 +178,78 @@ void main() { expect(song.groesseBytes, 1024); }); }); + + // ─── v2.55.2: Notification-Konfiguration ─── + + group('syncNotificationDetails (v2.55.2: HIGH + public)', () { + test('ongoing=true → Importance.high, Priority.high, showProgress', () { + final details = + SyncService.syncNotificationDetails(ongoing: true, progress: 5, maxProgress: 25); + + expect(details.importance, Importance.high); + expect(details.priority, Priority.high); + expect(details.visibility, NotificationVisibility.public); + expect(details.showProgress, isTrue); + expect(details.ongoing, isTrue); + expect(details.autoCancel, isFalse); + expect(details.progress, 5); + expect(details.maxProgress, 25); + expect(details.onlyAlertOnce, isTrue); + }); + + test('ongoing=false → autoCancel, kein Progress', () { + final details = SyncService.syncNotificationDetails(ongoing: false); + + expect(details.importance, Importance.high); + expect(details.priority, Priority.high); + expect(details.visibility, NotificationVisibility.public); + expect(details.showProgress, isFalse); + expect(details.ongoing, isFalse); + expect(details.autoCancel, isTrue); + expect(details.progress, 0); + expect(details.maxProgress, 0); + }); + + test('maxProgress=0 bei ongoing=true → default 1 (Division durch 0)', () { + final details = + SyncService.syncNotificationDetails(ongoing: true, progress: 0, maxProgress: 0); + + // maxProgress muss > 0 sein, sonst crasht die Notification-API + expect(details.maxProgress, 1); + }); + + test('channelId ist de.baka.melo.sync', () { + final details = SyncService.syncNotificationDetails(ongoing: false); + expect(details.channelId, 'de.baka.melo.sync'); + }); + }); + + // ─── v2.55.2: Lifecycle-Unabhängigkeit ─── + + group('SyncService unabhängig von RealtimeSyncService-Lifecycle', () { + test('laeuftGlobal wird NICHT durch pausiere() zurückgesetzt', () { + // SyncService.laeuftGlobal startet als false. + // Der Lifecycle (RealtimeSyncService.pausiere) darf diesen Zustand + // NICHT beeinflussen — es sind getrennte Systeme. + expect(SyncService.laeuftGlobal, isFalse); + + // Lifecycle: App geht in den Hintergrund + RealtimeSyncService().pausiere(); + + // SyncService muss unabhängig bleiben + expect(SyncService.laeuftGlobal, isFalse); + }); + + test('laeuftNotifier wird NICHT durch pausiere() verändert', () { + final vorher = SyncService.laeuftNotifier.value; + RealtimeSyncService().pausiere(); + expect(SyncService.laeuftNotifier.value, vorher); + }); + + test('syncBenachrichtigungGetippt wird NICHT durch pausiere() verändert', () { + final vorher = SyncService.syncBenachrichtigungGetippt.value; + RealtimeSyncService().pausiere(); + expect(SyncService.syncBenachrichtigungGetippt.value, vorher); + }); + }); }