From 8f4c3fa56f8a1d7711ebd94cea8080342760a65f Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 13:48:49 +0200 Subject: [PATCH] Implementierungsplan: Vollbild-Player als persistentes Overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 Tasks, TDD-Schritte, Spec-Abdeckung geprüft. --- .../2026-08-29-blur-oeffnen-transition.md | 1333 +++++++++++++++++ 1 file changed, 1333 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md diff --git a/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md b/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md new file mode 100644 index 0000000..36a105b --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md @@ -0,0 +1,1333 @@ +# Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `NowPlayingScreen` wird von einer per `Navigator.push` geöffneten Route zu einem persistenten Overlay in `HomeShell`, dessen Öffnen/Schließen live an der Zugdistanz der Wischgeste hängt (Blur, Cover-Wanderung) statt erst nach Abschluss einer festen Animation zu erscheinen. + +**Architecture:** Ein neuer `PlayerExpansionController` (ChangeNotifier um einen `AnimationController`) hält einen reinen Optik-Wert `progress` (0=Mini, 1=Vollbild). `HomeShell` stellt ihn bereit und rendert einen `Stack` aus Tab-Inhalt, bedingtem Blur-Layer, `MiniPlayer` und dem `NowPlayingScreen`-Inhalt, dessen Rechteck per `Rect.lerp` zwischen Mini- und Vollbild-Position interpoliert. Die bestehende Entscheidungslogik in `wischen.dart` bleibt unverändert und wird nur um den neuen, rein visuellen `progress`-Wert ergänzt. + +**Tech Stack:** Flutter (Dart), `provider` (State-Management, bereits im Projekt), reines Flutter-SDK für Animation (kein neues Package, Projekt-Konvention). + +**Spec:** `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md` + +## Global Constraints + +- Kein neues Package — alle Animationen mit Flutter-SDK-Bordmitteln (`AnimationController`, `BackdropFilter`, `Rect.lerp`), wie im übrigen Projekt (`MeloMotion`-Konvention). +- `wischen.dart` (`oeffnetVollbildMitStrecke`, `wischSchwelle`, `wischStreckeSchwelle`) bleibt **unverändert** — nur wiederverwendet. +- Reduce-Motion: jede neue Animation muss `MediaQuery.disableAnimationsOf(context)` respektieren (Sofortsprung statt Animation), analog zum bestehenden `MeloMotion.ruhig`-Muster. +- Deutsche Namenskonvention für neue Bezeichner, wie im übrigen Projekt (`SongZeile`, `LaufBalken`, `_CoverGrund`, …). +- `flutter analyze` und `flutter test` müssen nach jedem Task grün sein (Pre-Commit-Hook des Projekts prüft das ohnehin automatisch). + +--- + +## Datei-Überblick + +| Datei | Änderung | +|---|---| +| `lib/player/player_expansion_controller.dart` | **Neu** — der Controller | +| `test/player/player_expansion_controller_test.dart` | **Neu** — Unit-Tests | +| `lib/main.dart` | `HomeShell`: Controller erzeugen/bereitstellen, `Stack`-Umbau, `PopScope` erweitern | +| `lib/player/now_playing_screen.dart` | Von Scaffold/Route zu Overlay-Inhalt umgebaut, `Hero` entfernt, Schließgeste live | +| `lib/player/mini_player.dart` | Lokale `_versatz`-Logik durch Controller-Aufrufe ersetzt, eigenes Cover entfernt | +| `lib/library/song_list.dart` | `Navigator.push(NowPlayingScreen)` → `controller.open(context)` | +| `lib/playlists/playlist_detail_screen.dart` | dito | +| `lib/downloads/server_titel_screen.dart` | dito | +| `test/player/home_shell_expansion_test.dart` | **Neu** — Integrationstests | +| `CHANGELOG.md` | Eintrag | + +--- + +### Task 1: `PlayerExpansionController` + +**Files:** +- Create: `lib/player/player_expansion_controller.dart` +- Test: `test/player/player_expansion_controller_test.dart` + +**Interfaces:** +- Produces: + - `class PlayerExpansionController extends ChangeNotifier` + - `PlayerExpansionController({required TickerProvider vsync})` + - `double get progress` — 0.0…1.0 + - `void dragBy(double dy)` — `dy` ist `details.delta.dy` einer Wischgeste, unverändertes Vorzeichen (negativ = nach oben) + - `void dragEnd(BuildContext context, double strecke, double geschwindigkeit)` — `strecke`/`geschwindigkeit` wie in `wischen.dart` (negativ = nach oben) + - `void open(BuildContext context)` + - `void close(BuildContext context)` + - `const double oeffnenReferenzHoehe` (top-level, Wert `300.0`) + +- [ ] **Step 1: Schreibe die fehlschlagenden Tests** + +```dart +// test/player/player_expansion_controller_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/player_expansion_controller.dart'; + +void main() { + Widget mitMediaQuery(bool disableAnimations, WidgetBuilder builder) { + return MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: MaterialApp(home: Builder(builder: builder)), + ); + } + + testWidgets('progress startet bei 0', (tester) async { + late PlayerExpansionController controller; + await tester.pumpWidget(mitMediaQuery(false, (context) { + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets('dragBy nach oben erhöht progress, nach unten verringert ihn', + (tester) async { + late PlayerExpansionController controller; + await tester.pumpWidget(mitMediaQuery(false, (context) { + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-150); // halbe Referenzhöhe nach oben + expect(controller.progress, closeTo(0.5, 0.001)); + + controller.dragBy(75); // ein Viertel zurück nach unten + expect(controller.progress, closeTo(0.25, 0.001)); + controller.dispose(); + }); + + testWidgets('dragBy clampt auf [0, 1]', (tester) async { + late PlayerExpansionController controller; + await tester.pumpWidget(mitMediaQuery(false, (context) { + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-1000); + expect(controller.progress, 1.0); + controller.dragBy(2000); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets('open() fliegt zu 1, close() fliegt zu 0', (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + + controller.close(ctx); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets('open()/close() springen bei disableAnimations sofort', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(true, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + expect(controller.progress, 1.0); // ohne pumpAndSettle sofort da + controller.close(ctx); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Öffnen: ausreichende Strecke nach oben fliegt zu 1', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-200); + controller.dragEnd(ctx, -200, 0); // Strecke > wischStreckeSchwelle (80) + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Öffnen: zu kurze, langsame Geste fällt zurück auf 0', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-30); + controller.dragEnd(ctx, -30, 0); // unter wischStreckeSchwelle + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Schließen: ausreichende Strecke nach unten fliegt zu 0', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + await tester.pumpAndSettle(); + controller.dragBy(200); // nach unten, Betrag > Referenzhöhe/2 + controller.dragEnd(ctx, 200, 0); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Schließen: zu kurze, langsame Geste bleibt bei 1', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + await tester.pumpAndSettle(); + controller.dragBy(30); + controller.dragEnd(ctx, 30, 0); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + controller.dispose(); + }); +} +``` + +- [ ] **Step 2: Tests laufen lassen, sicherstellen dass sie fehlschlagen** + +Run: `flutter test test/player/player_expansion_controller_test.dart` +Expected: FAIL — `player_expansion_controller.dart` existiert nicht (Import-Fehler). + +- [ ] **Step 3: Implementierung schreiben** + +```dart +// lib/player/player_expansion_controller.dart +import 'package:flutter/material.dart'; + +import 'wischen.dart'; + +/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht — +/// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in +/// [wischen.dart]. +const oeffnenReferenzHoehe = 300.0; + +/// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1). +/// +/// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert +/// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang +/// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt). +class PlayerExpansionController extends ChangeNotifier { + PlayerExpansionController({required TickerProvider vsync}) + : _animation = AnimationController(vsync: vsync) { + _animation.addListener(notifyListeners); + } + + final AnimationController _animation; + + double get progress => _animation.value; + + /// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes + /// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv) + /// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste + /// symmetrisch, je nachdem, wo progress gerade steht. + void dragBy(double dy) { + _animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0); + } + + /// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben). + /// Bei einer Öffnen-Geste (strecke <= 0) direkt über + /// [oeffnetVollbildMitStrecke] entschieden; bei einer Schließen-Geste + /// (strecke >= 0, im Vollbild-Player nach unten gezogen) über dieselbe + /// Funktion mit gespiegeltem Vorzeichen — "stark genug nach unten" ist + /// spiegelbildlich zu "stark genug nach oben". + void dragEnd(BuildContext context, double strecke, double geschwindigkeit) { + final zielIstOffen = strecke <= 0 + ? oeffnetVollbildMitStrecke(strecke, geschwindigkeit) + : !oeffnetVollbildMitStrecke(-strecke, -geschwindigkeit); + _fliegeZu(context, zielIstOffen ? 1.0 : 0.0, aufwaerts: zielIstOffen); + } + + void open(BuildContext context) => _fliegeZu(context, 1.0, aufwaerts: true); + + void close(BuildContext context) => _fliegeZu(context, 0.0, aufwaerts: false); + + void _fliegeZu(BuildContext context, double ziel, {required bool aufwaerts}) { + if (MediaQuery.disableAnimationsOf(context)) { + _animation.value = ziel; + return; + } + _animation.fling(velocity: aufwaerts ? 1 : -1); + } + + @override + void dispose() { + _animation.dispose(); + super.dispose(); + } +} +``` + +- [ ] **Step 4: Tests laufen lassen, sicherstellen dass sie bestehen** + +Run: `flutter test test/player/player_expansion_controller_test.dart` +Expected: PASS (alle 9 Tests) + +- [ ] **Step 5: `flutter analyze` laufen lassen** + +Run: `flutter analyze --no-pub` +Expected: Keine Befunde + +- [ ] **Step 6: Commit** + +```bash +git add lib/player/player_expansion_controller.dart test/player/player_expansion_controller_test.dart +git commit -m "PlayerExpansionController: optischer Öffnen-Fortschritt für den Vollbild-Player" +``` + +--- + +### Task 2: Controller in `HomeShell` bereitstellen + Zurück-Taste + +**Files:** +- Modify: `lib/main.dart:153-269` (`_HomeShellState`) +- Test: `test/player/home_shell_expansion_test.dart` (neu, wächst in späteren Tasks weiter) + +**Interfaces:** +- Consumes: `PlayerExpansionController` aus Task 1 (`progress`, `open`, `close`) +- Produces: `PlayerExpansionController` ist über `context.read()`/`context.watch<...>()` überall unterhalb von `HomeShell` erreichbar. + +Diese Aufgabe ändert **nur** Bereitstellung + Zurück-Taste — der visuelle `Stack`-Umbau (Blur-Layer, Overlay-Inhalt) folgt in Task 4/5. Nach diesem Task sieht die App optisch noch unverändert aus. + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +```dart +// test/player/home_shell_expansion_test.dart +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) { + return ChangeNotifierProvider.value( + value: _controller, + child: PopScope( + canPop: _controller.progress == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _controller.close(context); + }, + 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); + + await tester.pageBack(); // simuliert Zurück-Taste/-Geste über PopScope + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + }); +} +``` + +- [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** + +Run: `flutter test test/player/home_shell_expansion_test.dart` +Expected: FAIL — `_TestShellState._controller` ist zwar vorhanden (Test-Double), aber `tester.pageBack()` popt hier noch nichts, weil `_TestShell` bislang ohne echte zweite Route existiert. **Hinweis für den Umsetzer:** Falls `pageBack()` in diesem isolierten Test-Double nicht greift (es gibt keine zweite Route zum Zurückgehen), stattdessen direkt `Navigator.maybePop(tester.element(find.byType(_TestShell)))` aufrufen, um denselben `PopScope`-Callback auszulösen. Passe den Test entsprechend an, bevor du fortfährst — das Ziel ist, dass `onPopInvokedWithResult` mit `didPop == false` durchläuft, wenn `progress > 0`. + +- [ ] **Step 3: `HomeShell` erweitern** + +In `lib/main.dart`, `_HomeShellState`: + +```dart +class _HomeShellState extends State + with WidgetsBindingObserver, TickerProviderStateMixin { + int _index = 0; + late final AutoScan _autoScan; + late final PlayerExpansionController _expansion; + + // ... bestehendes _geteilteAdresse-Feld unverändert ... + + @override + void initState() { + super.initState(); + _expansion = PlayerExpansionController(vsync: this); + // ... bestehender initState-Rumpf unverändert ... + } + + @override + void dispose() { + _expansion.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +``` + +`build()`s `PopScope` wird angepasst (bisheriger Kommentar zum reinen Tab-Fall bleibt erhalten, wird ergänzt): + +```dart + return ChangeNotifierProvider.value( + value: _expansion, + child: AnimatedBuilder( + animation: _expansion, + builder: (context, 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( + // ... bestehender Scaffold-Rumpf unverändert (Stack-Umbau folgt in Task 4) ... + ), + ), + ); +``` + +Import ergänzen: `import 'player/player_expansion_controller.dart';` + +- [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** + +Run: `flutter test test/player/home_shell_expansion_test.dart` +Expected: PASS + +- [ ] **Step 5: Volle Suite + Analyze** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Alles grün (bestehende Tests unverändert, da `HomeShell` optisch identisch bleibt) + +- [ ] **Step 6: Commit** + +```bash +git add lib/main.dart test/player/home_shell_expansion_test.dart +git commit -m "HomeShell: PlayerExpansionController bereitstellen, Zurück-Taste schließt offenen Player" +``` + +--- + +### Task 3: Die 3 Tap-only-Öffnen-Stellen migrieren + +**Files:** +- Modify: `lib/library/song_list.dart:382-396` (`SongZeile._zeile.onTap`) +- Modify: `lib/playlists/playlist_detail_screen.dart:127-150` (Song-`ListTile.onTap`) +- Modify: `lib/downloads/server_titel_screen.dart:73-97` (`_ServerTitelScreenState._spiele`) + +**Interfaces:** +- Consumes: `PlayerExpansionController.open(BuildContext)` aus Task 1, bereitgestellt seit Task 2 + +Alle drei Stellen folgen demselben Muster: nach erfolgreichem Abspielen `Navigator.push(MaterialPageRoute(builder: (_) => const NowPlayingScreen()))` → `context.read().open(context)`. Fehlerpfad/Snackbar-Logik bleibt unverändert. + +- [ ] **Step 1: `song_list.dart` ändern** + +Vorher (Zeile ~382-385): +```dart + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); +``` +Nachher: +```dart + if (!context.mounted) return; + context.read().open(context); +``` +Import `now_playing_screen.dart` entfernen, falls dort sonst nicht mehr gebraucht (prüfen — `song_list.dart` importiert es aktuell nur für diesen Push). Import `../player/player_expansion_controller.dart` ergänzen. + +- [ ] **Step 2: `playlist_detail_screen.dart` analog ändern** + +Vorher (Zeile ~137-140): +```dart + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); +``` +Nachher: +```dart + if (!context.mounted) return; + context.read().open(context); +``` +Importe analog anpassen. + +- [ ] **Step 3: `server_titel_screen.dart` analog ändern** + +Vorher (Zeile ~81-84): +```dart + if (!mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); +``` +Nachher: +```dart + if (!mounted) return; + context.read().open(context); +``` +Importe analog anpassen. + +- [ ] **Step 4: Bestehende Tests der drei Dateien laufen lassen** + +Run: `flutter test test/library/song_list_navigation_test.dart test/playlists/ test/downloads/ --no-pub` (exakte Testdateipfade beim Umsetzer per `find test -iname "*song_list*" -o -iname "*playlist_detail*" -o -iname "*server_titel*"` verifizieren, da nicht alle drei zwingend eigene Tests haben) +Expected: Tests, die bisher auf `Navigator.push`/`find.byType(NowPlayingScreen)` nach einem Tap prüften, müssen jetzt stattdessen prüfen, dass `PlayerExpansionController.progress` auf 1 geht (Provider im Testaufbau bereitstellen, analog zu Task 2s `_TestShell`-Muster). Fehlschlagende Tests hier anpassen, nicht überspringen. + +- [ ] **Step 5: Analyze + volle Suite** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Grün + +- [ ] **Step 6: Commit** + +```bash +git add lib/library/song_list.dart lib/playlists/playlist_detail_screen.dart lib/downloads/server_titel_screen.dart test/ +git commit -m "3 Tap-Stellen öffnen den Vollbild-Player jetzt über PlayerExpansionController statt Navigator.push" +``` + +--- + +### Task 4: `HomeShell` — Blur-Layer + Mini-Player-Fade + +**Files:** +- Modify: `lib/main.dart` (`_HomeShellState.build`, Scaffold-`body`) +- Test: `test/player/home_shell_expansion_test.dart` (erweitert) + +**Interfaces:** +- Consumes: `PlayerExpansionController.progress` (Task 1) +- Produces: `Stack`-Struktur, auf die Task 5 den Overlay-Inhalt aufsetzt + +- [ ] **Step 1: Fehlschlagenden Test ergänzen** + +```dart + 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))); + await tester.pump(const Duration(milliseconds: 50)); // mitten in der Animation + expect(find.byType(BackdropFilter), findsOneWidget); + await tester.pumpAndSettle(); + }); +``` + +Ergänze in `_TestShell.build` denselben `Stack`-Aufbau wie unten für `HomeShell` vorgesehen (Blur-Layer + `AnimatedBuilder`), damit dieser Test unabhängig vom vollen `HomeShell` (Audio-Handler etc.) läuft. + +- [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** + +Run: `flutter test test/player/home_shell_expansion_test.dart` +Expected: FAIL — `_TestShell` hat noch keinen `BackdropFilter` + +- [ ] **Step 3: `HomeShell`s `Scaffold.body` umbauen** + +Vorher (`lib/main.dart`, im `Scaffold`): +```dart + body: Column( + children: [ + Expanded(child: IndexedStack(index: _index, children: tabs)), + const MiniPlayer(), + ], + ), +``` +Nachher: +```dart + body: Stack( + children: [ + Column( + children: [ + Expanded(child: IndexedStack(index: _index, children: tabs)), + const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player + ], + ), + if (_expansion.progress > 0) + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: _expansion.progress * 20.0, + sigmaY: _expansion.progress * 20.0, + ), + child: Container(color: Colors.transparent), + ), + ), + 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: const MiniPlayer(), + ), + ), + ), + // NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier. + ], + ), +``` +Import ergänzen: `import 'dart:ui';` (für `ImageFilter`). + +Da dieser `build()`-Rumpf bereits innerhalb des `AnimatedBuilder` aus Task 2 liegt (der auf `_expansion` hört), löst jede `progress`-Änderung automatisch ein Rebuild aus — kein zusätzlicher `Consumer`/`watch` hier nötig. + +- [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** + +Run: `flutter test test/player/home_shell_expansion_test.dart` +Expected: PASS + +- [ ] **Step 5: Analyze + volle Suite** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Grün + +- [ ] **Step 6: Commit** + +```bash +git add lib/main.dart test/player/home_shell_expansion_test.dart +git commit -m "HomeShell: Blur-Layer und Mini-Player-Fade an progress gekoppelt" +``` + +--- + +### Task 5: `NowPlayingScreen` zu Overlay-Inhalt umbauen + +**Files:** +- Modify: `lib/player/now_playing_screen.dart` (kompletter Umbau von `NowPlayingScreen`, `_CoverGrund` bleibt inhaltlich, `_Cover`/`Hero` entfällt) +- Modify: `lib/main.dart` (Overlay-Inhalt in den `Stack` aus Task 4 einhängen) +- Test: `test/player/now_playing_screen_test.dart` (bestehende Datei prüfen/anpassen, falls vorhanden — sonst neu) + +**Interfaces:** +- Consumes: `PlayerExpansionController.progress` (Task 1) +- Produces: `NowPlayingScreen` ist kein `StatelessWidget` mehr, das eine ganze `Scaffold`-Seite baut, sondern liefert seinen Inhalt für ein vorgegebenes Rechteck; `HomeShell` übernimmt Positionierung/Größe. + +Dies ist der größte Einzelschritt. Aufteilung in Unterschritte: + +- [ ] **Step 1: `NowPlayingScreen` von `Scaffold` zu einfachem Inhalt umbauen** + +`NowPlayingScreen.build` verliert `Scaffold`/`AppBar`/`extendBodyBehindAppBar` — die AppBar-Icons (Songtext, Sleep-Timer, Warteschlange) wandern in eine eigene `Row` oben im Inhalt, gerahmt von `SafeArea`. Die `_CoverGrund`-Farbverlauf-Logik bleibt unverändert bestehen, wird aber jetzt direkt vom neuen Wurzel-Widget verwendet statt vom `Scaffold`. + +```dart +/// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress +/// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route. +class NowPlayingScreen extends StatelessWidget { + const NowPlayingScreen({super.key}); + + void _showLyrics(BuildContext context, String songId) { + if (songId.isEmpty) return; + showModalBottomSheet( + context: context, + builder: (ctx) => _LyricsSheet(songId: songId), + ); + } + + @override + Widget build(BuildContext context) { + final handler = context.read(); + return _CoverGrund( + handler: handler, + child: Material( + color: Colors.transparent, + child: SafeArea( + child: Column( + children: [ + _VollbildLeiste(handler: handler, onLyrics: _showLyrics), + Expanded( + child: GestureDetector( + onVerticalDragStart: (_) {}, // s. Task 8 + child: StreamBuilder( + stream: handler.mediaItem, + builder: (context, snapshot) { + final item = snapshot.data; + if (item == null) { + return const Center(child: Text('Nichts in Wiedergabe')); + } + return LayoutBuilder( + builder: (context, raum) { + final nebeneinander = raum.maxWidth > raum.maxHeight; + final inhalt = _Angaben(item: item, handler: handler); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: nebeneinander + ? Row(children: [ + Expanded(child: Center(child: _CoverPlatzhalter())), + const SizedBox(width: MeloSpace.lg), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: inhalt, + ), + ), + ), + ]) + : Column(children: [ + Expanded(child: Center(child: _CoverPlatzhalter())), + const SizedBox(height: MeloSpace.lg), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: inhalt, + ), + ), + ]), + ); + }, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Platzhalter im Layout — das eigentliche Cover rendert `_WanderndesCover` +/// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom +/// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann. +class _CoverPlatzhalter extends StatelessWidget { + const _CoverPlatzhalter(); + @override + Widget build(BuildContext context) => const AspectRatio(aspectRatio: 1, child: SizedBox()); +} + +/// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als +/// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier +/// nicht mehr. +class _VollbildLeiste extends StatelessWidget { + const _VollbildLeiste({required this.handler, required this.onLyrics}); + final MeloAudioHandler handler; + final void Function(BuildContext, String) onLyrics; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + StreamBuilder( + stream: handler.mediaItem, + builder: (context, snapshot) { + final item = snapshot.data; + if (item == null) return const SizedBox.shrink(); + final songId = songIdOf(item) ?? navidromeIdOf(item) ?? ''; + return IconButton( + tooltip: songId.isEmpty + ? 'Kein Songtext für diesen Titel verfügbar' + : 'Songtext', + icon: const Icon(Icons.lyrics), + onPressed: songId.isEmpty ? null : () => onLyrics(context, songId), + ); + }, + ), + _SleepTimerButton(handler: handler), + IconButton( + tooltip: 'Warteschlange', + icon: const Icon(Icons.queue_music), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const QueueScreen()), + ), + ), + ], + ); + } +} +``` + +`_Angaben`, `_Herz`, `_ProgressBar`, `_Controls`, `_SleepTimerButton`, `_LyricsSheet`, `_Mitlaufend`, `_CoverGrund`/`_CoverGrundState` bleiben **unverändert** (nur `_Cover`/`coverHeldenName`/`Hero` entfallen — Cover-Rendering übernimmt Task 6 vollständig). + +- [ ] **Step 2: `HomeShell`s `Stack` um den Overlay-Inhalt ergänzen** + +In `lib/main.dart`, im `Stack` aus Task 4, nach dem Mini-Player-`Positioned`: + +```dart + AnimatedBuilder( + animation: _expansion, + builder: (context, child) { + final p = _expansion.progress; + if (p == 0) return const SizedBox.shrink(); + return Positioned.fromRect( + rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!, + child: Opacity( + // Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec. + opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0), + child: const NowPlayingScreen(), + ), + ); + }, + ), +``` + +Mit zwei kleinen Hilfsmethoden in `_HomeShellState`: + +```dart + Rect _miniRect(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight; + return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe); + } + + Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context); +``` + +- [ ] **Step 3: Bestehenden `now_playing_screen_test.dart` (falls vorhanden) prüfen/anpassen** + +Run: `find test -iname "*now_playing*"` — falls eine Datei existiert, die `NowPlayingScreen` bisher in ein `MaterialApp(home: NowPlayingScreen())` pumpt (als eigene Route), auf `pumpWidget(MaterialApp(home: Scaffold(body: NowPlayingScreen())))` umstellen (kein Scaffold/AppBar mehr in `NowPlayingScreen` selbst, Test muss also selbst einen Rahmen stellen) und Assertions auf entfallene `AppBar`-Icons in die neue `_VollbildLeiste`-Struktur überführen. + +- [ ] **Step 4: Analyze + volle Suite** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Grün. Bekannter Stolperstein: verwaiste Imports (`Hero`/`coverHeldenName` nicht mehr referenziert) — `flutter analyze` zeigt das als `unused_element`/`unused_import`. + +- [ ] **Step 5: Commit** + +```bash +git add lib/player/now_playing_screen.dart lib/main.dart test/ +git commit -m "NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut" +``` + +--- + +### Task 6: `_WanderndesCover` — Cover-Interpolation statt `Hero` + +**Files:** +- Modify: `lib/player/now_playing_screen.dart` (neue Komponente `_WanderndesCover`, ersetzt `_CoverPlatzhalter`-Leerstelle inhaltlich über eine eigene `Stack`-Ebene in `HomeShell`) +- Modify: `lib/main.dart` (Cover-Ebene im `Stack` ergänzen) +- Modify: `lib/player/mini_player.dart` (eigenes `Hero`/`CoverImage` entfernen) +- Test: `test/player/wanderndes_cover_test.dart` (neu) + +**Interfaces:** +- Consumes: `PlayerExpansionController.progress` +- Produces: `class WanderndesCover extends StatelessWidget` (public — wird sowohl aus `now_playing_screen.dart`-Kontext als auch direkt aus `main.dart` referenziert, daher kein führender Unterstrich) + - `const WanderndesCover({required double progress, required Uri? artUri})` + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +```dart +// test/player/wanderndes_cover_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/now_playing_screen.dart'; + +void main() { + testWidgets('Rect bei progress=0 entspricht dem Mini-Player-Rechteck', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: WanderndesCover( + progress: 0, + artUri: null, + miniRect: const Rect.fromLTWH(0, 700, 400, 52), + vollbildRect: const Rect.fromLTWH(0, 0, 400, 800), + ), + ), + )); + final box = tester.getRect(find.byType(WanderndesCover)); + expect(box, const Rect.fromLTWH(0, 700, 400, 52)); + }); + + testWidgets('Rect bei progress=1 entspricht dem Vollbild-Rechteck', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: WanderndesCover( + progress: 1, + artUri: null, + miniRect: const Rect.fromLTWH(0, 700, 400, 52), + vollbildRect: const Rect.fromLTWH(0, 0, 400, 800), + ), + ), + )); + final box = tester.getRect(find.byType(WanderndesCover)); + expect(box, const Rect.fromLTWH(0, 0, 400, 800)); + }); + + testWidgets('Rect bei progress=0.5 liegt in der Mitte', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: WanderndesCover( + progress: 0.5, + artUri: null, + miniRect: const Rect.fromLTWH(0, 700, 400, 52), + vollbildRect: const Rect.fromLTWH(0, 0, 400, 800), + ), + ), + )); + final box = tester.getRect(find.byType(WanderndesCover)); + expect(box, Rect.lerp( + const Rect.fromLTWH(0, 700, 400, 52), + const Rect.fromLTWH(0, 0, 400, 800), + 0.5, + )); + }); +} +``` + +- [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** + +Run: `flutter test test/player/wanderndes_cover_test.dart` +Expected: FAIL — `WanderndesCover` existiert nicht + +- [ ] **Step 3: Implementierung schreiben** + +In `lib/player/now_playing_screen.dart` (öffentliche Komponente, ersetzt die alte `_Cover`-Klasse und `coverHeldenName`): + +```dart +/// Cover-Bild, das zwischen Mini-Player- und Vollbild-Rechteck wandert. +/// +/// Ersetzt die frühere `Hero`-Animation: die feuert nur bei echten +/// Navigator-Transitions, und `NowPlayingScreen` ist seit dem Overlay-Umbau +/// keine Route mehr. Positionierung/Größe kommen direkt aus [progress], +/// live an die Zugstrecke der Wischgeste gekoppelt. +class WanderndesCover extends StatelessWidget { + const WanderndesCover({ + super.key, + required this.progress, + required this.artUri, + required this.miniRect, + required this.vollbildRect, + }); + + final double progress; + final Uri? artUri; + final Rect miniRect; + final Rect vollbildRect; + + static const _miniRadius = 6.0; + static const _vollbildRadius = 16.0; + + @override + Widget build(BuildContext context) { + final rect = Rect.lerp(miniRect, vollbildRect, progress)!; + final radius = lerpDouble(_miniRadius, _vollbildRadius, progress)!; + return Positioned.fromRect( + rect: rect, + child: CoverImage(artUri: artUri, radius: radius), + ); + } +} +``` + +Import ergänzen: `import 'dart:ui' show lerpDouble;` (falls `dart:ui` noch nicht importiert ist — in `now_playing_screen.dart` bislang nicht der Fall, separat von Task 4s `dart:ui`-Import in `main.dart`). + +`_Cover`-Klasse und `coverHeldenName`-Konstante aus `now_playing_screen.dart` entfernen (durch `_CoverPlatzhalter`/`WanderndesCover` ersetzt). + +- [ ] **Step 4: `HomeShell` verdrahten** + +In `lib/main.dart`, `Stack` (Task 5s Overlay-Positioned ergänzen um eine eigene Cover-Ebene, die on top of allem liegt und permanent — auch bei `progress == 0` — das Cover an der Mini-Player-Position zeigt): + +```dart + StreamBuilder( + stream: context.read().mediaItem, + builder: (context, snapshot) { + final item = snapshot.data; + if (item == null) return const SizedBox.shrink(); + return AnimatedBuilder( + animation: _expansion, + builder: (context, child) => WanderndesCover( + progress: _expansion.progress, + artUri: item.artUri, + miniRect: _miniCoverRect(context), + vollbildRect: _vollbildCoverRect(context), + ), + ); + }, + ), +``` + +Mit `_miniCoverRect`/`_vollbildCoverRect` als Ergänzung zu `_miniRect`/`_vollbildRect` aus Task 5 — konkrete Werte (Cover-Größe 52 im Mini-Player wie bisher, quadratisch mit `raum.maxWidth`-Bezug im Vollbild wie bisher `_Cover`s `AspectRatio(aspectRatio: 1, ...)`) beim Umsetzen anhand der bestehenden Layout-Werte aus `mini_player.dart`/dem alten `_Cover` ableiten. + +- [ ] **Step 5: `mini_player.dart` — eigenes Cover entfernen** + +In `_LeisteState.build`, den `Hero(tag: coverHeldenName, child: CoverImage(...))`-Block ersatzlos streichen (das per `WanderndesCover` in `HomeShell` gerenderte Cover deckt exakt dieselbe Position bei `progress == 0` ab) — die umgebende `Row` bekommt an der Stelle stattdessen einen leeren `SizedBox(width: 52)` als Platzhalter, damit Titel/Bedienung nicht springen. + +- [ ] **Step 6: Test laufen lassen, sicherstellen dass er besteht** + +Run: `flutter test test/player/wanderndes_cover_test.dart` +Expected: PASS + +- [ ] **Step 7: Analyze + volle Suite** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Grün + +- [ ] **Step 8: Commit** + +```bash +git add lib/player/now_playing_screen.dart lib/main.dart lib/player/mini_player.dart test/player/wanderndes_cover_test.dart +git commit -m "WanderndesCover ersetzt Hero-Übergang: Cover wandert live mit progress" +``` + +--- + +### Task 7: Mini-Player-Geste an den Controller anschließen + +**Files:** +- Modify: `lib/player/mini_player.dart:55-181` (`_LeisteState`) +- Test: bestehende Mini-Player-Tests prüfen/anpassen (`find test -iname "*mini_player*"`) + +**Interfaces:** +- Consumes: `PlayerExpansionController.dragBy`/`dragEnd`/`open` (Task 1) + +- [ ] **Step 1: Bestehenden Test lesen und Erwartung anpassen (RED)** + +Bestehende Tests, die `_versatz`/das Zurückschnappen des Mini-Players direkt prüfen, auf Prüfung von `PlayerExpansionController.progress` umstellen (Provider im Testaufbau bereitstellen). Wo ein Test bisher `Navigator.push`/`find.byType(NowPlayingScreen)` nach Tap/Wisch erwartete, jetzt `controller.progress == 1` nach `pumpAndSettle()` erwarten. + +- [ ] **Step 2: `_LeisteState` umbauen** + +```dart +class _LeisteState extends State<_Leiste> { + /// Für die horizontale Titel-Wechsel-Geste (unverändert). + double _horizontalVersatz = 0; + bool _ziehtHorizontalGerade = false; + + /// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie + /// bisher `_versatz.dy`, nur ohne die Optik-Verantwortung (die liegt jetzt + /// im PlayerExpansionController). Wird für [PlayerExpansionController. + /// dragEnd] gebraucht, das dieselbe rohe Strecke wie wischen.dart erwartet; + /// aus `progress` ließe sich das wegen des Clampings nicht mehr exakt + /// zurückrechnen. + double _vertikaleStrecke = 0; + + void _oeffne(BuildContext context) => + context.read().open(context); + + @override + Widget build(BuildContext context) { + final texte = Theme.of(context).textTheme; + final handler = widget.handler; + final item = widget.item; + return DecoratedBox( + decoration: const BoxDecoration( + color: MeloTheme.surface, + border: Border(top: BorderSide(color: MeloTheme.border)), + ), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onVerticalDragStart: (_) => _vertikaleStrecke = 0, + onVerticalDragUpdate: (details) { + _vertikaleStrecke += details.delta.dy; + context.read().dragBy(details.delta.dy); + }, + onVerticalDragEnd: (details) => context + .read() + .dragEnd(context, _vertikaleStrecke, + details.velocity.pixelsPerSecond.dy), + onHorizontalDragUpdate: (details) => setState(() { + _ziehtHorizontalGerade = true; + _horizontalVersatz += details.delta.dx; + }), + onHorizontalDragEnd: (details) { + final wechsel = titelWechselMitStrecke( + _horizontalVersatz, details.velocity.pixelsPerSecond.dx); + setState(() { + _ziehtHorizontalGerade = false; + _horizontalVersatz = 0; + }); + switch (wechsel) { + case Wechsel.naechster: + handler.skipToNext(); + case Wechsel.vorheriger: + handler.skipToPrevious(); + case null: + break; + } + }, + child: InkWell( + onTap: () => _oeffne(context), + child: AnimatedContainer( + duration: _ziehtHorizontalGerade + ? Duration.zero + : MeloMotion.ruhig(context, MeloMotion.normal), + curve: MeloMotion.curve, + transform: Matrix4.translationValues(_horizontalVersatz, 0, 0), + height: MiniPlayer.hoehe, + child: Row( + children: [ + const SizedBox(width: MeloSpace.md), + const SizedBox(width: 52), // Platz für WanderndesCover, s. Task 6 + const SizedBox(width: MeloSpace.md), + // ... Titel/Künstler-Column, _Bedienung unverändert ... + ], + ), + ), + ), + ), + _Fortschritt(handler: handler), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 3: Tests laufen lassen, bis grün** + +Run: `flutter test test/player/ --no-pub` +Expected: PASS — bei Fehlschlägen die `strecke`-Herleitung gemäß obigem Hinweis korrigieren, nicht den Test aufweichen. + +- [ ] **Step 4: Analyze + volle Suite** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Grün + +- [ ] **Step 5: Commit** + +```bash +git add lib/player/mini_player.dart test/ +git commit -m "Mini-Player-Wischgeste steuert PlayerExpansionController statt lokalen Versatz" +``` + +--- + +### Task 8: Schließgeste im Vollbild-Player — symmetrisch live + +**Files:** +- Modify: `lib/player/now_playing_screen.dart` (der `GestureDetector` aus Task 5 Step 1, bislang nur `onVerticalDragStart: (_) {}` als Platzhalter) +- Test: bestehende Now-Playing-Tests erweitern + +**Interfaces:** +- Consumes: `PlayerExpansionController.dragBy`/`dragEnd` (Task 1) + +- [ ] **Step 1: Fehlschlagenden Test schreiben/erweitern** + +In `test/player/home_shell_expansion_test.dart` (oder `now_playing_screen_test.dart`, je nachdem wo der volle `HomeShell`-Kontext verfügbar ist): + +```dart + testWidgets('Ziehen nach unten im Vollbild-Player verringert progress live', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: _TestShell())); + final ctx = tester.element(find.byType(_TestShell)); + final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; + controller.open(ctx); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + + await tester.drag(find.byType(NowPlayingScreen), const Offset(0, 150)); + await tester.pump(); + expect(controller.progress, lessThan(1.0)); + }); +``` + +- [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** + +Run: `flutter test --no-pub -t "Ziehen nach unten"` +Expected: FAIL — der `GestureDetector` in `NowPlayingScreen` reagiert noch nicht auf `onVerticalDragUpdate` + +- [ ] **Step 3: Gestenlogik in `NowPlayingScreen` ergänzen** + +Der `GestureDetector` aus Task 5 Step 1 (`onVerticalDragStart: (_) {}`) wird zu einem `StatefulWidget`-Wrapper (`_SchliessGeste`), da eine lokale Rohstrecke über die Gestendauer mitgeführt werden muss (analog zum bisherigen `_versatz`-Muster in `mini_player.dart`): + +```dart +class _SchliessGeste extends StatefulWidget { + const _SchliessGeste({required this.child}); + final Widget child; + + @override + State<_SchliessGeste> createState() => _SchliessGesteState(); +} + +class _SchliessGesteState extends State<_SchliessGeste> { + double _strecke = 0; + + @override + Widget build(BuildContext context) { + final controller = context.read(); + return GestureDetector( + onVerticalDragStart: (_) => _strecke = 0, + onVerticalDragUpdate: (details) { + _strecke += details.delta.dy; + controller.dragBy(details.delta.dy); + }, + onVerticalDragEnd: (details) => controller.dragEnd( + context, _strecke, details.velocity.pixelsPerSecond.dy), + child: widget.child, + ); + } +} +``` + +In `NowPlayingScreen.build` (Task 5 Step 1) ersetzt `_SchliessGeste` den bisherigen Platzhalter-`GestureDetector` um den `StreamBuilder`-Bereich. + +- [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** + +Run: `flutter test --no-pub -t "Ziehen nach unten"` +Expected: PASS + +- [ ] **Step 5: Analyze + volle Suite** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Grün + +- [ ] **Step 6: Commit** + +```bash +git add lib/player/now_playing_screen.dart test/ +git commit -m "Vollbild-Player: Schließgeste live an progress gekoppelt, symmetrisch zum Öffnen" +``` + +--- + +### Task 9: Integrationstest über den ganzen Zyklus + Spec-Abdeckung prüfen + +**Files:** +- Modify: `test/player/home_shell_expansion_test.dart` (finaler End-to-End-Test) + +- [ ] **Step 1: End-to-End-Test schreiben** + +```dart + testWidgets( + 'Kompletter Zyklus: Ziehen öffnet, Blur erscheint, Zurück-Taste schließt', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: _TestShell())); + final ctx = tester.element(find.byType(_TestShell)); + final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; + + controller.dragBy(-250); + await tester.pump(); + expect(controller.progress, closeTo(250 / oeffnenReferenzHoehe, 0.01)); + expect(find.byType(BackdropFilter), findsOneWidget); + + controller.dragEnd(ctx, -250, 0); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + expect(find.byType(BackdropFilter), findsNothing); + }); +``` + +- [ ] **Step 2: Test laufen lassen** + +Run: `flutter test test/player/home_shell_expansion_test.dart` +Expected: PASS (falls FAIL: Ursache in den Tasks 1-8 suchen, nicht den Test aufweichen) + +- [ ] **Step 3: Spec-Abdeckung gegenprüfen** + +Gegen `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md` durchgehen: `PlayerExpansionController` ✓ (Task 1), Blur-Layer ✓ (Task 4), `WanderndesCover` ✓ (Task 6), 4 Öffnen-Stellen ✓ (Task 3+7), Zurück-Taste ✓ (Task 2), symmetrische Schließgeste ✓ (Task 8). Keine offenen Punkte. + +- [ ] **Step 4: Commit** + +```bash +git add test/player/home_shell_expansion_test.dart +git commit -m "Integrationstest: kompletter Öffnen/Schließen-Zyklus über PlayerExpansionController" +``` + +--- + +### Task 10: CHANGELOG + Abschluss-Verifikation + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: CHANGELOG-Eintrag ergänzen** (oberster Eintrag, Format ✅/🔧/🐛 laut Projekt-Konvention, siehe bestehende Einträge) + +```markdown +## [Unreleased] + +✅ Vollbild-Player öffnet/schließt jetzt live mit der Wischgeste (Blur + Cover-Wanderung), nicht erst nach fester Animation +🔧 NowPlayingScreen ist kein Navigator-Route mehr, sondern persistentes Overlay in HomeShell (PlayerExpansionController); Hero-Cover-Übergang durch WanderndesCover ersetzt +``` + +(Vor den bereits vorhandenen `## [Unreleased]`-Einträgen — nicht darüber eine zweite Überschrift anlegen, sondern in den bestehenden Abschnitt einsortieren.) + +- [ ] **Step 2: Volle Verifikation** + +Run: `flutter analyze --no-pub && flutter test --no-pub` +Expected: Alles grün, keine Warnungen + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "CHANGELOG: Live-Blur-Übergang für den Vollbild-Player" +``` + +- [ ] **Step 4: Push** + +```bash +git push -u origin feature/blur-oeffnen-transition +```