diff --git a/lib/main.dart b/lib/main.dart index 4bee8ae..ad97598 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -115,9 +115,35 @@ Future main() async { runApp(const MeloApp()); } -class MeloApp extends StatelessWidget { +class MeloApp extends StatefulWidget { const MeloApp({super.key}); + @override + State createState() => _MeloAppState(); +} + +class _MeloAppState extends State with TickerProviderStateMixin { + // Muss oberhalb des Navigators bereitgestellt werden (Review-Fund): auch + // per Navigator.push geöffnete Screens (z.B. PlaylistDetailScreen, + // ServerTitelScreen) rufen .open(context) auf. Als HomeShell-internen + // Provider steckte der Controller nur innerhalb von HomeShell.build() — für + // eine gepushte Route (ein Geschwister von HomeShell im selben + // Navigator/Overlay, kein Nachfahre) war er dort unsichtbar und + // context.read() warf ProviderNotFoundException. + late final PlayerExpansionController _expansion; + + @override + void initState() { + super.initState(); + _expansion = PlayerExpansionController(vsync: this); + } + + @override + void dispose() { + _expansion.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MultiProvider( @@ -135,6 +161,8 @@ class MeloApp extends StatelessWidget { ChangeNotifierProvider.value(value: _ytSearch), ChangeNotifierProvider.value(value: _sync), ChangeNotifierProvider.value(value: _downloads), + ChangeNotifierProvider.value( + value: _expansion), ], child: MaterialApp( title: 'Melo', @@ -153,11 +181,9 @@ class HomeShell extends StatefulWidget { State createState() => _HomeShellState(); } -class _HomeShellState extends State - with WidgetsBindingObserver, TickerProviderStateMixin { +class _HomeShellState extends State with WidgetsBindingObserver { int _index = 0; late final AutoScan _autoScan; - late final PlayerExpansionController _expansion; /// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll. String? _geteilteAdresse; @@ -167,7 +193,6 @@ class _HomeShellState extends State @override void initState() { super.initState(); - _expansion = PlayerExpansionController(vsync: this); final db = context.read(); final lib = context.read(); _autoScan = AutoScan( @@ -196,7 +221,6 @@ class _HomeShellState extends State @override void dispose() { - _expansion.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -229,43 +253,47 @@ class _HomeShellState extends State const FavoritesScreen(), const YoutubeSearchScreen(), ]; - return ChangeNotifierProvider.value( - value: _expansion, - // Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird - // laut Flutter-Semantik bei jedem _expansion-Tick NICHT neu gebaut, - // das ist hier auch nicht nötig: PopScope selbst braucht nur einen - // aktuellen Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste - // gedrückt), keine Frame-genaue Aktualisierung während des Ziehens. - // Der Blur-Layer/Mini-Player-Fade in Task 4 braucht dagegen echte - // Live-Reaktivität und bekommt dafür einen eigenen, davon unabhängigen - // AnimatedBuilder direkt an seiner Stelle im Stack — siehe - // Reaktivitäts-Regel in Global Constraints. - child: Consumer( - builder: (context, expansion, child) => PopScope( - // Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset: - // Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch - // den Tab wechseln. - canPop: expansion.progress == 0 && _index == 0, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - if (expansion.progress > 0) { - expansion.close(context); - } else { - setState(() => _index = 0); - } - }, - child: child!, - ), - child: Scaffold( + // Nur die Referenz holen, NICHT abonnieren (kein context.watch): die + // Scaffold-weiten AnimatedBuilder unten hören direkt am Controller + // (animation: expansion) und aktualisieren sich selbst live. Ein + // context.watch hier würde stattdessen bei JEDEM _expansion-Tick den + // kompletten HomeShell.build() (inkl. Scaffold-Rumpf) neu aufrufen. + final expansion = context.read(); + // Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird + // laut Flutter-Semantik bei jedem expansion-Tick NICHT neu gebaut, das + // ist hier auch nicht nötig: PopScope selbst braucht nur einen aktuellen + // Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste gedrückt), keine + // Frame-genaue Aktualisierung während des Ziehens. Der Blur-Layer/Mini- + // Player-Fade in Task 4 braucht dagegen echte Live-Reaktivität und + // bekommt dafür einen eigenen, davon unabhängigen AnimatedBuilder direkt + // an seiner Stelle im Stack — siehe Reaktivitäts-Regel in Global + // Constraints. + return Consumer( + builder: (context, expansion, child) => PopScope( + // Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset: + // Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch + // den Tab wechseln. + canPop: expansion.progress == 0 && _index == 0, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (expansion.progress > 0) { + expansion.close(context); + } else { + setState(() => _index = 0); + } + }, + child: child!, + ), + child: Scaffold( extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar body: Stack( children: [ AnimatedBuilder( - animation: _expansion, + animation: expansion, builder: (context, child) => IgnorePointer( - ignoring: _expansion.progress > 0, + ignoring: expansion.progress > 0, child: ExcludeSemantics( - excluding: _expansion.progress > 0, + excluding: expansion.progress > 0, child: child, ), ), @@ -277,9 +305,9 @@ class _HomeShellState extends State ), ), AnimatedBuilder( - animation: _expansion, + animation: expansion, builder: (context, child) { - final p = _expansion.progress; + final p = expansion.progress; if (p == 0) return const SizedBox.shrink(); return Positioned.fill( child: BackdropFilter( @@ -290,15 +318,15 @@ class _HomeShellState extends State }, ), AnimatedBuilder( - animation: _expansion, + animation: expansion, builder: (context, child) => Positioned( left: 0, right: 0, bottom: 0, child: Opacity( - opacity: (1 - _expansion.progress).clamp(0.0, 1.0), + opacity: (1 - expansion.progress).clamp(0.0, 1.0), child: IgnorePointer( - ignoring: _expansion.progress > 0, + ignoring: expansion.progress > 0, child: child, ), ), @@ -306,9 +334,9 @@ class _HomeShellState extends State child: const MiniPlayer(), ), AnimatedBuilder( - animation: _expansion, + animation: expansion, builder: (context, child) { - final p = _expansion.progress; + final p = expansion.progress; return Positioned.fromRect( rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!, child: Visibility( @@ -331,9 +359,9 @@ class _HomeShellState extends State final item = snapshot.data; if (item == null) return const SizedBox.shrink(); return AnimatedBuilder( - animation: _expansion, + animation: expansion, builder: (context, child) => WanderndesCover( - progress: _expansion.progress, + progress: expansion.progress, artUri: item.artUri, miniRect: _miniCoverRect(context), vollbildRect: _vollbildCoverRect(context), @@ -346,11 +374,11 @@ class _HomeShellState extends State // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. bottomNavigationBar: AnimatedBuilder( - animation: _expansion, + animation: expansion, builder: (context, child) => Opacity( - opacity: (1 - _expansion.progress).clamp(0.0, 1.0), + opacity: (1 - expansion.progress).clamp(0.0, 1.0), child: IgnorePointer( - ignoring: _expansion.progress > 0, + ignoring: expansion.progress > 0, child: child, ), ), @@ -382,8 +410,7 @@ class _HomeShellState extends State ), ), ), - ), - ); + ); } Rect _miniRect(BuildContext context) { diff --git a/test/hauptmenue_test.dart b/test/hauptmenue_test.dart index 2f08869..79590ab 100644 --- a/test/hauptmenue_test.dart +++ b/test/hauptmenue_test.dart @@ -7,6 +7,7 @@ import 'package:melo/library/library_service.dart'; import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/player_expansion_controller.dart'; import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/download_service.dart'; import 'package:melo/services/melo_cloud_service.dart'; @@ -52,6 +53,7 @@ void main() { late DownloadService downloads; late SyncService sync; MeloAudioHandler? handler; + PlayerExpansionController? expansion; setUp(() { SharedPreferences.setMockInitialValues({}); @@ -81,10 +83,14 @@ void main() { // Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer // von drift im Test-Rahmen hängen — und ein anschließendes db.close() // käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt). - Widget bauen() { + Widget bauen(WidgetTester tester) { // Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen // Plattform-Kanal zu, den es im setUp noch nicht gibt. handler ??= MeloAudioHandler(db: db); + // PlayerExpansionController sitzt seit dem Provider-Scope-Fix (main.dart) + // oberhalb von MaterialApp/HomeShell, nicht mehr innerhalb von + // HomeShell.build() — hier deshalb dieselbe Verdrahtung nachgebaut. + expansion ??= PlayerExpansionController(vsync: tester); return MultiProvider( providers: [ Provider.value(value: db), @@ -99,6 +105,8 @@ void main() { ChangeNotifierProvider.value(value: ytSearch), ChangeNotifierProvider.value(value: downloads), ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider.value( + value: expansion!), ], child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()), ); @@ -113,6 +121,8 @@ void main() { await tester.pumpWidget(const SizedBox()); handler?.dispose(); handler = null; + expansion?.dispose(); + expansion = null; // Die Zeit ein Stück vorstellen: beim Abbauen melden sich die // Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein // pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann @@ -126,7 +136,7 @@ void main() { ); testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async { - await tester.pumpWidget(bauen()); + await tester.pumpWidget(bauen(tester)); await tester.pump(); final leiste = @@ -138,7 +148,7 @@ void main() { testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm', (tester) async { - await tester.pumpWidget(bauen()); + await tester.pumpWidget(bauen(tester)); await tester.pump(); final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio; @@ -163,7 +173,7 @@ void main() { final ziel = i; testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin', (tester) async { - await tester.pumpWidget(bauen()); + await tester.pumpWidget(bauen(tester)); await tester.pump(); await tester.tap(eintrag(hauptmenue[ziel])); @@ -182,7 +192,7 @@ void main() { testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async { // Läge es woanders, wäre es kein Hauptmenü mehr. - await tester.pumpWidget(bauen()); + await tester.pumpWidget(bauen(tester)); await tester.pump(); final menue = tester.getRect(find.byType(BottomNavigationBar)); diff --git a/test/home_shell_test.dart b/test/home_shell_test.dart index 23f82d8..6631599 100644 --- a/test/home_shell_test.dart +++ b/test/home_shell_test.dart @@ -7,6 +7,7 @@ import 'package:melo/library/library_service.dart'; import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/player_expansion_controller.dart'; import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/download_service.dart'; import 'package:melo/services/melo_cloud_service.dart'; @@ -39,6 +40,7 @@ void main() { final playlists = PlaylistService(db); final handler = MeloAudioHandler(db: db); final auth = BakaAuth(speicher: _MemorySpeicher()); + final expansion = PlayerExpansionController(vsync: tester); await tester.pumpWidget(MultiProvider( providers: [ @@ -59,6 +61,8 @@ void main() { ChangeNotifierProvider( create: (_) => SyncService( db: db, cloud: MeloCloudService(auth: auth))), + ChangeNotifierProvider.value( + value: expansion), ], child: const MaterialApp(home: HomeShell()), )); @@ -70,6 +74,7 @@ void main() { ['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']); handler.dispose(); + expansion.dispose(); await db.close(); }); @@ -80,6 +85,7 @@ void main() { final playlists = PlaylistService(db); final handler = MeloAudioHandler(db: db); final auth = BakaAuth(speicher: _MemorySpeicher()); + final expansion = PlayerExpansionController(vsync: tester); await tester.pumpWidget(MultiProvider( providers: [ @@ -100,6 +106,8 @@ void main() { ChangeNotifierProvider( create: (_) => SyncService( db: db, cloud: MeloCloudService(auth: auth))), + ChangeNotifierProvider.value( + value: expansion), ], child: const MaterialApp(home: HomeShell()), )); @@ -137,6 +145,7 @@ void main() { // einem tester.tap. await tester.pumpWidget(const SizedBox()); handler.dispose(); + expansion.dispose(); await tester.pump(const Duration(milliseconds: 1)); }); } diff --git a/test/player/home_shell_real_expansion_test.dart b/test/player/home_shell_real_expansion_test.dart index 0f0a63f..bd8e94b 100644 --- a/test/player/home_shell_real_expansion_test.dart +++ b/test/player/home_shell_real_expansion_test.dart @@ -44,6 +44,7 @@ void main() { final playlists = PlaylistService(db); final handler = MeloAudioHandler(db: db); final auth = BakaAuth(speicher: _MemorySpeicher()); + final expansion = PlayerExpansionController(vsync: tester); await tester.pumpWidget(MultiProvider( providers: [ @@ -64,6 +65,8 @@ void main() { ChangeNotifierProvider( create: (_) => SyncService(db: db, cloud: MeloCloudService(auth: auth))), + ChangeNotifierProvider.value( + value: expansion), ], child: const MaterialApp(home: HomeShell()), )); @@ -71,13 +74,11 @@ void main() { expect(find.byType(BackdropFilter), findsNothing); - // PlayerExpansionController wird von HomeShell selbst per - // ChangeNotifierProvider INNERHALB seines eigenen build() bereitgestellt - // — der Provider sitzt also unterhalb (nicht oberhalb) des HomeShell- - // Elements im Baum. Provider.of von einem HomeShell-Element aus würde - // deshalb ins Leere laufen (Ancestor-Lookup findet nur Vorfahren, nicht - // die eigenen Nachfahren) — stattdessen ein Element unterhalb nehmen, - // z.B. MiniPlayer, das immer im Stack steckt, auch ohne laufenden Titel. + // PlayerExpansionController sitzt seit dem Provider-Scope-Fix oberhalb + // von MaterialApp/HomeShell (analog zu MeloApps MultiProvider in + // main.dart), nicht mehr innerhalb von HomeShell.build() — jedes Element + // im Baum sieht denselben Controller, hier über ein Element unterhalb von + // HomeShell (MiniPlayer, immer im Stack, auch ohne laufenden Titel). final descendantContext = tester.element(find.byType(MiniPlayer)); final controller = Provider.of( descendantContext, @@ -98,6 +99,7 @@ void main() { // test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers). await tester.pumpWidget(const SizedBox()); handler.dispose(); + expansion.dispose(); await tester.pump(const Duration(milliseconds: 1)); }); @@ -111,6 +113,7 @@ void main() { final playlists = PlaylistService(db); final handler = MeloAudioHandler(db: db); final auth = BakaAuth(speicher: _MemorySpeicher()); + final expansion = PlayerExpansionController(vsync: tester); await tester.pumpWidget(MultiProvider( providers: [ @@ -127,10 +130,13 @@ void main() { ChangeNotifierProvider( create: (_) => YtSearchService(auth: auth)), ChangeNotifierProvider( - create: (_) => DownloadService(db: db, navidrome: NavidromeService())), + create: (_) => + DownloadService(db: db, navidrome: NavidromeService())), ChangeNotifierProvider( create: (_) => SyncService(db: db, cloud: MeloCloudService(auth: auth))), + ChangeNotifierProvider.value( + value: expansion), ], child: const MaterialApp(home: HomeShell()), )); @@ -169,6 +175,7 @@ void main() { await tester.pumpWidget(const SizedBox()); handler.dispose(); + expansion.dispose(); await tester.pump(const Duration(milliseconds: 1)); }); } diff --git a/test/player/provider_scope_navigator_push_test.dart b/test/player/provider_scope_navigator_push_test.dart new file mode 100644 index 0000000..c26db85 --- /dev/null +++ b/test/player/provider_scope_navigator_push_test.dart @@ -0,0 +1,150 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:drift/drift.dart' show Value, driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/library_service.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/main.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/mini_player.dart'; +import 'package:melo/player/player_expansion_controller.dart'; +import 'package:melo/playlists/playlist_detail_screen.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/melo_cloud_service.dart'; +import 'package:melo/services/navidrome_service.dart'; +import 'package:melo/services/offline_mode.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/services/yt_download_service.dart'; +import 'package:melo/services/yt_search_service.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff — +/// dieselbe Klasse wie in playlist_detail_navigation_test.dart. +class _FakeAudioHandler extends MeloAudioHandler { + _FakeAudioHandler(MeloDb db) : super(db: db); + + @override + Future loadPlaylist(List items, {int startIndex = 0}) async { + queue.add(items); + if (items.isNotEmpty) mediaItem.add(items[startIndex]); + return 0; + } +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets( + 'Per Navigator.push geöffneter PlaylistDetailScreen (wie aus ' + 'playlists_screen.dart:120) sieht PlayerExpansionController — echtes ' + 'HomeShell, echter Navigator.push, kein direkt daneben geklebter ' + 'Test-Provider (Review-Fund: Provider war bisher nur INNERHALB von ' + 'HomeShell.build() bereitgestellt, eine gepushte Route ist im Baum ' + 'aber ein Geschwister von HomeShell, kein Nachfahre)', (tester) async { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final db = MeloDb(NativeDatabase.memory()); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/music/roses.mp3', + title: 'Roses', + artist: const Value('Neoni'), + dateAddedMs: 0, + updatedAtMs: 0, + )); + final playlistId = await db.createPlaylist('Road Trip'); + await db.addSongToPlaylist(playlistId, 'song-1', 0); + final playlist = Playlist( + id: playlistId, + name: 'Road Trip', + createdAtMs: 0, + updatedAtMs: 0, + deleted: false, + ); + + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = _FakeAudioHandler(db); + final auth = BakaAuth(speicher: _MemorySpeicher()); + final expansion = PlayerExpansionController(vsync: tester); + addTearDown(expansion.dispose); + + // Dieselbe Provider-Verdrahtung wie home_shell_real_expansion_test.dart, + // PlayerExpansionController aber jetzt OBERHALB von MaterialApp/HomeShell + // — genau wie MeloApps MultiProvider nach dem Fix (main.dart), nicht mehr + // nur INNERHALB von HomeShell.build() bereitgestellt. + await tester.pumpWidget(MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ChangeNotifierProvider(create: (_) => OfflineMode()), + ChangeNotifierProvider(create: (_) => auth), + ChangeNotifierProvider( + create: (_) => YtDownloadService(auth: auth)), + ChangeNotifierProvider( + create: (_) => YtSearchService(auth: auth)), + ChangeNotifierProvider( + create: (_) => DownloadService(db: db, navidrome: NavidromeService())), + ChangeNotifierProvider( + create: (_) => + SyncService(db: db, cloud: MeloCloudService(auth: auth))), + ChangeNotifierProvider.value( + value: expansion), + ], + child: const MaterialApp(home: HomeShell()), + )); + await tester.pump(); + + // Navigator.push GENAU wie playlists_screen.dart:120 — von einem Kontext + // INNERHALB des echten HomeShell-Baums aus (MiniPlayer steckt immer im + // Stack, auch ohne laufenden Titel), nicht von einem manuell daneben + // gebauten Test-Baum. + final homeShellContext = tester.element(find.byType(MiniPlayer)); + Navigator.of(homeShellContext).push(MaterialPageRoute( + builder: (_) => PlaylistDetailScreen(playlist: playlist), + )); + await tester.pumpAndSettle(); + + expect(find.text('Roses'), findsOneWidget); + + // Tap wie ein echter Nutzer — löst intern playSongs() und danach + // context.read().open(context) aus. + await tester.tap(find.text('Roses')); + await tester.pumpAndSettle(); + + // Bug-Symptom: die ProviderNotFoundException landet im try/catch von + // PlaylistDetailScreen und zeigt eine "Wiedergabe fehlgeschlagen"- + // Snackbar, statt dass der Player öffnet. + expect(find.textContaining('Wiedergabe fehlgeschlagen'), findsNothing, + reason: 'context.read() darf hier keine ' + 'ProviderNotFoundException werfen.'); + // Beweis, dass .open() tatsächlich denselben (einzigen) Controller + // erreicht hat, nicht nur, dass keine Exception flog. + expect(expansion.progress, 1.0); + + // Aufräum-Reihenfolge wie home_shell_real_expansion_test.dart: erst den + // Baum abbauen, dann den Handler disposen (offener Positions-Timer). + await tester.pumpWidget(const SizedBox()); + handler.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }); +}