diff --git a/lib/main.dart b/lib/main.dart index fcaef9e..33b6f42 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'library/search_screen.dart'; import 'player/audio_effects.dart'; import 'player/audio_handler.dart'; import 'player/mini_player.dart'; +import 'player/player_expansion_controller.dart'; import 'services/baka_auth.dart'; import 'services/logger_service.dart'; import 'services/download_service.dart'; @@ -150,9 +151,11 @@ class HomeShell extends StatefulWidget { State createState() => _HomeShellState(); } -class _HomeShellState extends State with WidgetsBindingObserver { +class _HomeShellState extends State + with WidgetsBindingObserver, TickerProviderStateMixin { int _index = 0; late final AutoScan _autoScan; + late final PlayerExpansionController _expansion; /// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll. String? _geteilteAdresse; @@ -162,6 +165,7 @@ class _HomeShellState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); + _expansion = PlayerExpansionController(vsync: this); final db = context.read(); final lib = context.read(); _autoScan = AutoScan( @@ -190,6 +194,7 @@ class _HomeShellState extends State with WidgetsBindingObserver { @override void dispose() { + _expansion.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -222,47 +227,67 @@ class _HomeShellState extends State with WidgetsBindingObserver { const FavoritesScreen(), const YoutubeSearchScreen(), ]; - return PopScope( - // Tab-Wechsel laufen nur über setState, nie über Navigator.push — die - // Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum - // Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen. - canPop: _index == 0, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) setState(() => _index = 0); - }, - child: Scaffold( - body: Column( - children: [ - Expanded(child: IndexedStack(index: _index, children: tabs)), - const MiniPlayer(), - ], + 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!, ), - // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund - // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. - bottomNavigationBar: DecoratedBox( - decoration: const BoxDecoration( - border: Border(top: BorderSide(color: MeloTheme.border)), + child: Scaffold( + body: Column( + children: [ + Expanded(child: IndexedStack(index: _index, children: tabs)), + const MiniPlayer(), + ], ), - child: BottomNavigationBar( - currentIndex: _index, - onTap: _goTo, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.headphones), label: 'Meine Musik'), - BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), - BottomNavigationBarItem( - icon: Icon(Icons.download_outlined), - activeIcon: Icon(Icons.download), - label: 'Download'), - BottomNavigationBarItem( - icon: Icon(Icons.favorite_border), - activeIcon: Icon(Icons.favorite), - label: 'Favoriten'), - BottomNavigationBarItem( - icon: Icon(Icons.smart_display_outlined), - activeIcon: Icon(Icons.smart_display), - label: 'YT-Suche'), - ], + // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund + // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. + bottomNavigationBar: DecoratedBox( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: MeloTheme.border)), + ), + child: BottomNavigationBar( + currentIndex: _index, + onTap: _goTo, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.headphones), label: 'Meine Musik'), + BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), + BottomNavigationBarItem( + icon: Icon(Icons.download_outlined), + activeIcon: Icon(Icons.download), + label: 'Download'), + BottomNavigationBarItem( + icon: Icon(Icons.favorite_border), + activeIcon: Icon(Icons.favorite), + label: 'Favoriten'), + BottomNavigationBarItem( + icon: Icon(Icons.smart_display_outlined), + activeIcon: Icon(Icons.smart_display), + label: 'YT-Suche'), + ], + ), ), ), ), diff --git a/test/player/home_shell_expansion_test.dart b/test/player/home_shell_expansion_test.dart new file mode 100644 index 0000000..2ea5b8e --- /dev/null +++ b/test/player/home_shell_expansion_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:melo/player/player_expansion_controller.dart'; + +// Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell +// nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen. +// Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet, +// sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test +// sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double. +class _TestShell extends StatefulWidget { + const _TestShell(); + @override + State<_TestShell> createState() => _TestShellState(); +} + +class _TestShellState extends State<_TestShell> with TickerProviderStateMixin { + late final PlayerExpansionController _controller; + + @override + void initState() { + super.initState(); + _controller = PlayerExpansionController(vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Eigener AnimatedBuilder um PopScope, damit canPop bei jedem + // _controller-Tick neu ausgewertet wird (Reaktivitäts-Regel aus dem + // Plan) — ohne ihn bliebe canPop auf dem Wert des allerersten Builds + // eingefroren, weil sonst nichts hier ein Rebuild auslöst. + return ChangeNotifierProvider.value( + value: _controller, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) => PopScope( + canPop: _controller.progress == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _controller.close(context); + }, + child: child!, + ), + child: const Scaffold(body: SizedBox()), + ), + ); + } +} + +void main() { + testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: _TestShell())); + final controller = + tester.state<_TestShellState>(find.byType(_TestShell))._controller; + + controller.open(tester.element(find.byType(_TestShell))); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + + // pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne + // zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) — + // Navigator.maybePop() löst denselben PopScope-Callback aus. + await Navigator.maybePop(tester.element(find.byType(_TestShell))); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + }); +}