diff --git a/lib/main.dart b/lib/main.dart index 33b6f42..ff14021 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; @@ -255,38 +256,93 @@ class _HomeShellState extends State child: child!, ), child: Scaffold( - body: Column( + extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar + body: Stack( children: [ - Expanded(child: IndexedStack(index: _index, children: tabs)), - const MiniPlayer(), + AnimatedBuilder( + animation: _expansion, + builder: (context, child) => IgnorePointer( + ignoring: _expansion.progress > 0, + child: ExcludeSemantics( + excluding: _expansion.progress > 0, + child: child, + ), + ), + child: Column( + children: [ + Expanded(child: IndexedStack(index: _index, children: tabs)), + const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player + ], + ), + ), + AnimatedBuilder( + animation: _expansion, + builder: (context, child) { + final p = _expansion.progress; + if (p == 0) return const SizedBox.shrink(); + return Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0), + child: Container(color: Colors.transparent), + ), + ); + }, + ), + AnimatedBuilder( + animation: _expansion, + builder: (context, child) => Positioned( + left: 0, + right: 0, + bottom: 0, + child: Opacity( + opacity: (1 - _expansion.progress).clamp(0.0, 1.0), + child: IgnorePointer( + ignoring: _expansion.progress > 0, + child: child, + ), + ), + ), + child: const MiniPlayer(), + ), + // NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier. ], ), // 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)), + bottomNavigationBar: AnimatedBuilder( + animation: _expansion, + builder: (context, child) => Opacity( + opacity: (1 - _expansion.progress).clamp(0.0, 1.0), + child: IgnorePointer( + ignoring: _expansion.progress > 0, + child: child, + ), ), - 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'), - ], + child: 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 index 2ea5b8e..2a6edbf 100644 --- a/test/player/home_shell_expansion_test.dart +++ b/test/player/home_shell_expansion_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -9,7 +11,8 @@ import 'package:melo/player/player_expansion_controller.dart'; // 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(); + const _TestShell({this.tabContent = const SizedBox.shrink()}); + final Widget tabContent; @override State<_TestShell> createState() => _TestShellState(); } @@ -46,7 +49,38 @@ class _TestShellState extends State<_TestShell> with TickerProviderStateMixin { }, child: child!, ), - child: const Scaffold(body: SizedBox()), + // Derselbe Stack-Aufbau wie HomeShell (Task 4): Blur-Layer und + // Tab-Blocker bekommen jeweils ihren eigenen AnimatedBuilder, NICHT + // nur den äußeren aus dem PopScope-Consumer oben (Reaktivitäts-Regel + // aus dem Plan) — sonst würde dieser Test den genau davor gefundenen + // Bug (Review-Panel) nicht abdecken. + child: Scaffold( + body: Stack( + children: [ + AnimatedBuilder( + animation: _controller, + builder: (context, child) => IgnorePointer( + ignoring: _controller.progress > 0, + child: child, + ), + child: widget.tabContent, + ), + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final p = _controller.progress; + if (p == 0) return const SizedBox.shrink(); + return Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0), + child: Container(color: Colors.transparent), + ), + ); + }, + ), + ], + ), + ), ), ); } @@ -70,4 +104,42 @@ void main() { await tester.pumpAndSettle(); expect(controller.progress, 0.0); }); + + testWidgets('Blur-Layer erscheint erst, wenn progress > 0', (tester) async { + await tester.pumpWidget(const MaterialApp(home: _TestShell())); + expect(find.byType(BackdropFilter), findsNothing); + + final controller = + tester.state<_TestShellState>(find.byType(_TestShell))._controller; + controller.open(tester.element(find.byType(_TestShell))); + // Ein zusätzlicher, leerer pump() startet den Ticker — sein erster Tick + // liefert laut Flutter-Konvention elapsed=0 (Baseline), erst der + // folgende pump(duration) rückt die Animation tatsächlich vorwärts. + // Ohne diesen Zwischenschritt bliebe progress nach nur einem + // pump(duration) fälschlich bei 0.0 (per Debug-Ausgabe verifiziert). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // mitten in der Animation + expect(find.byType(BackdropFilter), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('Tab-Inhalt ist bei offenem Player nicht mehr antippbar', + (tester) async { + var getappt = false; + await tester.pumpWidget(MaterialApp( + home: _TestShell( + tabContent: GestureDetector( + onTap: () => getappt = true, + child: const SizedBox(width: 200, height: 200), + ), + ), + )); + final controller = + tester.state<_TestShellState>(find.byType(_TestShell))._controller; + controller.open(tester.element(find.byType(_TestShell))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(GestureDetector), warnIfMissed: false); + expect(getappt, isFalse); + }); }