diff --git a/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md b/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md index 36a105b..a1298c9 100644 --- a/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md +++ b/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md @@ -17,6 +17,7 @@ - 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). +- **Reaktivitäts-Regel (nach Review-Panel ergänzt):** Jede Stelle, die `_expansion.progress` live liest, braucht ihren **eigenen, eng geschnittenen** `AnimatedBuilder(animation: _expansion, ...)`/`Consumer`, ausgewertet in dessen `builder:`-Callback — **niemals** im `child:`-Parameter eines fremden `AnimatedBuilder`/`Consumer`, und niemals in der Annahme, ein äußerer reaktiver Wrapper mache tiefer verschachtelte Nachfahren automatisch reaktiv. `AnimatedBuilder.child`/`Consumer.child` werden von Flutter bei jedem `notifyListeners()`-Tick bewusst **nicht** neu gebaut — das ist eine Performance-Optimierung, kein „wird schon mitgezogen". Diese Regel wurde nötig, weil eine erste Fassung dieses Plans genau diesen Fehler enthielt (Task 4 verließ sich auf Task 2s äußeren `AnimatedBuilder`, der Blur-Layer wäre nie live gewesen — siehe Review-Panel-Befund). --- @@ -32,7 +33,8 @@ | `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 | +| `test/player/home_shell_expansion_test.dart` | **Neu** — Integrationstests (gegen `_TestShell`) | +| `test/player/home_shell_real_expansion_test.dart` | **Neu** — Integrationstest gegen das echte `HomeShell` (Task 9) | | `CHANGELOG.md` | Eintrag | --- @@ -235,6 +237,7 @@ Expected: FAIL — `player_expansion_controller.dart` existiert nicht (Import-Fe // lib/player/player_expansion_controller.dart import 'package:flutter/material.dart'; +import '../shared/theme.dart'; import 'wischen.dart'; /// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht — @@ -260,7 +263,10 @@ class PlayerExpansionController extends ChangeNotifier { /// [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. + /// symmetrisch, je nachdem, wo progress gerade steht. Bewusst rekursiv auf + /// dem bereits geclampten Wert (nicht auf der aufsummierten Rohstrecke): + /// so reagiert eine Richtungsumkehr mitten in der Geste sofort, auch wenn + /// die Geste vorher schon über [oeffnenReferenzHoehe] hinausgezogen wurde. void dragBy(double dy) { _animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0); } @@ -270,24 +276,40 @@ class PlayerExpansionController extends ChangeNotifier { /// [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". + /// spiegelbildlich zu "stark genug nach oben". Fliegt danach mit der + /// echten Loslass-Geschwindigkeit als Startimpuls zum entschiedenen Ziel. 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); + if (MediaQuery.disableAnimationsOf(context)) { + _animation.value = zielIstOffen ? 1.0 : 0.0; + return; + } + // -geschwindigkeit/oeffnenReferenzHoehe, weil dragBy() ein negatives dy + // (nach oben) in steigenden progress übersetzt — dieselbe Umrechnung + // rückwärts. Zeigt die tatsächliche Geschwindigkeit nicht zum + // entschiedenen Ziel (z.B. ein Aufwärtswisch, der am Ende minimal + // zurückfedert), erzwingt ein moderater Mindestimpuls die Zielrichtung, + // statt fling() mit widersprüchlichem Vorzeichen zu verwirren. + final rohimpuls = -geschwindigkeit / oeffnenReferenzHoehe; + final zeigtZumZiel = zielIstOffen ? rohimpuls > 0 : rohimpuls < 0; + final impuls = zeigtZumZiel ? rohimpuls : (zielIstOffen ? 1.0 : -1.0); + _animation.fling(velocity: impuls); } - void open(BuildContext context) => _fliegeZu(context, 1.0, aufwaerts: true); + void open(BuildContext context) => _animiereZu(context, 1.0); - void close(BuildContext context) => _fliegeZu(context, 0.0, aufwaerts: false); + void close(BuildContext context) => _animiereZu(context, 0.0); - void _fliegeZu(BuildContext context, double ziel, {required bool aufwaerts}) { + /// Für Tap-Auslöser (kein Ziehen, keine Loslass-Geschwindigkeit) — anders + /// als [dragEnd] mit fester Kurve/Dauer aus [MeloMotion], nicht als Fling. + void _animiereZu(BuildContext context, double ziel) { if (MediaQuery.disableAnimationsOf(context)) { _animation.value = ziel; return; } - _animation.fling(velocity: aufwaerts ? 1 : -1); + _animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve); } @override @@ -435,17 +457,25 @@ class _HomeShellState extends State ```dart return ChangeNotifierProvider.value( value: _expansion, - child: AnimatedBuilder( - animation: _expansion, - builder: (context, child) => PopScope( + // 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, + canPop: expansion.progress == 0 && _index == 0, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (_expansion.progress > 0) { - _expansion.close(context); + if (expansion.progress > 0) { + expansion.close(context); } else { setState(() => _index = 0); } @@ -492,6 +522,8 @@ git commit -m "HomeShell: PlayerExpansionController bereitstellen, Zurück-Taste 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. +**Bekannte Zwischenlücke (Review-Panel-Fund, akzeptiert):** Nach diesem Task tut ein Tap auf einen Song an allen drei Stellen sichtbar nichts mehr (Titel spielt bereits, aber weder die alte Route noch das neue Overlay erscheinen — Letzteres kommt erst in Task 4/5). Das ist wie bei Task 2 ein bewusster Zwischenstand ohne vollständige UI, kein Fehler in diesem Task selbst. `flutter test` bleibt trotzdem grün, weil die Tests in Step 4 exakt auf `controller.progress == 1` statt auf ein sichtbares Overlay prüfen (das Overlay-Rendering ist nicht Teil dieses Tasks). Wer die App zwischen Task 3 und Task 5 manuell auf dem Gerät testet, sieht diese Lücke — sie schließt sich mit Task 5s Commit. + - [ ] **Step 1: `song_list.dart` ändern** Vorher (Zeile ~382-385): @@ -583,9 +615,31 @@ git commit -m "3 Tap-Stellen öffnen den Vollbild-Player jetzt über PlayerExpan 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); + }); ``` -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. +**Hinweis für den Umsetzer:** `_TestShell` braucht dafür einen optionalen `tabContent`-Parameter (Default z.B. `SizedBox.shrink()`), der anstelle eines festen Platzhalters in die Stack-Struktur eingesetzt wird — kleine Erweiterung von `_TestShell` aus Task 2, kein neues Konzept. + +Ergänze in `_TestShell.build` denselben `Stack`-Aufbau wie unten für `HomeShell` vorgesehen — **mit dem eigenen, lokalen `AnimatedBuilder` um Blur-Layer und Mini-Player** (nicht nur den äußeren `Consumer` aus Task 2, der macht diese Ebene NICHT reaktiv, siehe Reaktivitäts-Regel in Global Constraints) — damit dieser Test unabhängig vom vollen `HomeShell` (Audio-Handler etc.) läuft UND tatsächlich beweist, dass der Blur bei laufender Geste live erscheint, nicht erst beim nächsten zufälligen Rebuild. - [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** @@ -603,37 +657,55 @@ Vorher (`lib/main.dart`, im `Scaffold`): ], ), ``` -Nachher: +Nachher — **wichtig:** Blur-Layer und Mini-Player-Fade bekommen jeweils ihren **eigenen** `AnimatedBuilder`, ausgewertet in dessen `builder:`, nicht im `child:` eines fremden Wrappers (siehe Reaktivitäts-Regel). Der Tab-Inhalt bekommt zusätzlich `IgnorePointer`/`ExcludeSemantics` bei `progress > 0`, damit er unter dem Blur weder antippbar noch für TalkBack erreichbar bleibt: ```dart + extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar body: Stack( children: [ - 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) => 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 + ], + ), ), - if (_expansion.progress > 0) - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: _expansion.progress * 20.0, - sigmaY: _expansion.progress * 20.0, + 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: 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(), ), ), + child: const MiniPlayer(), ), // NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier. ], @@ -641,7 +713,24 @@ Nachher: ``` 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. +Jede der drei Ebenen (Tab-Inhalt-Blocker, Blur, Mini-Player-Fade) trägt ihren eigenen `AnimatedBuilder` und reagiert damit unabhängig und live auf jeden `progress`-Tick — der äußere `Consumer` aus Task 2 dient ausschließlich `PopScope`, nicht diesen drei Ebenen (siehe Reaktivitäts-Regel in Global Constraints; das war der Kernfehler, den das Review-Panel an dieser Stelle in einer früheren Fassung gefunden hat). + +**Zusätzliche Folge von `extendBody: true` (Review-Panel-Nachtrag):** Ohne diese Zeile stimmte `MediaQuery.sizeOf(context)` (von Task 5s `_vollbildRect` verwendet) nicht mit der tatsächlichen `Scaffold.body`-Höhe überein — mit ihr erstreckt sich `body` jetzt aber unter die weiterhin fest sichtbare `bottomNavigationBar:`. Die soll bei vollständig geöffnetem Player nicht mehr sichtbar/antippbar sein (echtes "Vollbild"), also bekommt sie dieselbe Fade-Behandlung wie der Mini-Player. `bottomNavigationBar:` im `Scaffold` (unverändert an ihrer bisherigen Stelle, nicht Teil des `body`-`Stack`) wird umschlossen: +```dart + 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: DecoratedBox( + // ... bisheriger Inhalt (Haarlinie + BottomNavigationBar) unverändert ... + ), + ), +``` - [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** @@ -696,7 +785,14 @@ class NowPlayingScreen extends StatelessWidget { @override Widget build(BuildContext context) { final handler = context.read(); - return _CoverGrund( + // Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor: + // transparent) auf dem durchgehend dunklen Theme lief (siehe + // MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die + // Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die + // Icons je nach Systemzustand zufällig dunkel auf dunklem Grund. + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: _CoverGrund( handler: handler, child: Material( color: Colors.transparent, @@ -754,17 +850,25 @@ class NowPlayingScreen extends StatelessWidget { ), ), ), + ), ); } } -/// Platzhalter im Layout — das eigentliche Cover rendert `_WanderndesCover` +/// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche +/// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt +/// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio) +/// von außen nachzurechnen. +final coverPlatzhalterKey = GlobalKey(); + +/// 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()); + Widget build(BuildContext context) => + AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox()); } /// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als @@ -814,26 +918,33 @@ class _VollbildLeiste extends StatelessWidget { - [ ] **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`: +In `lib/main.dart`, im `Stack` aus Task 4, nach dem Mini-Player-`Positioned`. **Wichtig — nicht `if (p == 0) return SizedBox.shrink()` verwenden:** das würde `NowPlayingScreen` bei jedem vollständigen Schließen aus dem Baum entfernen und `_CoverGrundState` disposen — exakt der State-Verlust, den die Spec als behobenen Nachteil des alten Route-Ansatzes benennt (siehe Spec „Verhaltensänderung“). Stattdessen `Visibility` mit `maintainState: true`, das den Subtree gemountet lässt, aber bei `p == 0` weder malt noch Touch-/Screenreader-Ereignisse durchlässt: ```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(), + child: Visibility( + visible: p > 0, + maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec + maintainAnimation: true, + 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: child, + ), ), ); }, + child: const NowPlayingScreen(), ), ``` +`NowPlayingScreen` hängt hier bewusst wieder als statisches `AnimatedBuilder.child` (nicht im `builder`) — das ist diesmal korrekt, weil `NowPlayingScreen` selbst nichts direkt von `_expansion.progress` liest (nur seine Position/Sichtbarkeit/Deckkraft von außen gesteuert wird); seine eigene Reaktivität (Songtitel, Fortschritt, …) läuft unverändert über eigene `StreamBuilder`. + Mit zwei kleinen Hilfsmethoden in `_HomeShellState`: ```dart @@ -1011,7 +1122,24 @@ In `lib/main.dart`, `Stack` (Task 5s Overlay-Positioned ergänzen um eine eigene ), ``` -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. +`_miniCoverRect` ist eine reine Formel wie `_miniRect` (feste 52×52-Größe, linksbündig mit `MeloSpace.md`-Abstand, vertikal mittig in `MiniPlayer.hoehe` — Werte aus dem bisherigen `mini_player.dart`-Layout übernehmen). + +`_vollbildCoverRect` ist **nicht** per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter` sitzt in `NowPlayingScreen`s eigenem responsivem `LayoutBuilder` (Row-vs-Column-Weiche, `AspectRatio(1)` innerhalb `Expanded`/`Padding(horizontal: 24)`) — eine externe Nachrechnung dieser Logik in `_HomeShellState` würde bei jeder künftigen Layout-Änderung an `NowPlayingScreen` stillschweigend auseinanderlaufen. Stattdessen wird die tatsächliche Position **gemessen**: `_CoverPlatzhalter` bekommt einen `GlobalKey` (`coverPlatzhalterKey`, exportiert aus `now_playing_screen.dart`), `_vollbildCoverRect` liest ihn aus: +```dart + Rect _vollbildCoverRect(BuildContext context) { + final box = coverPlatzhalterKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) { + // Vor dem ersten Layout (z.B. ganz am Anfang, progress noch 0) — + // Notlösung: Platzhalter mittig im Vollbild-Rechteck annehmen, bis + // der erste Frame mit progress > 0 eine echte Messung liefert. + final voll = _vollbildRect(context); + final seite = voll.width - 48; + return Rect.fromLTWH(voll.left + 24, voll.top + 80, seite, seite); + } + return box.localToGlobal(Offset.zero) & box.size; + } +``` +Da `NowPlayingScreen` dank `Visibility(maintainState: true)` (Task 5) auch bei `progress == 0` durchgehend im Baum bleibt (nur unsichtbar), ist `_CoverPlatzhalter` fast immer schon gemessen — der Notlösungs-Zweig greift nur im allerersten Frame vor dem ersten Layout-Pass. - [ ] **Step 5: `mini_player.dart` — eigenes Cover entfernen** @@ -1252,9 +1380,12 @@ git commit -m "Vollbild-Player: Schließgeste live an progress gekoppelt, symmet ### 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) +- Modify: `test/player/home_shell_expansion_test.dart` (finaler End-to-End-Test gegen `_TestShell`) +- Create: `test/player/home_shell_real_expansion_test.dart` (Integrationstest gegen das ECHTE `HomeShell`) -- [ ] **Step 1: End-to-End-Test schreiben** +**Wichtiger Nachtrag (Review-Panel-Fund):** Alle bisherigen Tests (Tasks 2–8) laufen gegen `_TestShell`, einen separat gepflegten Nachbau von `HomeShell` — praktisch für isolierte Tests ohne Audio-Handler-Boilerplate, aber er hätte den in einer früheren Planfassung enthaltenen `AnimatedBuilder.child`-Bug (siehe Global Constraints, Reaktivitäts-Regel) nicht gefangen, weil der Fehler ausschließlich im echten `lib/main.dart` steckte. Dieser Task ergänzt deshalb zusätzlich einen Test gegen das **echte** `HomeShell`, nach dem Muster von `test/home_shell_test.dart` (Provider-Setup dort abschreiben). + +- [ ] **Step 1: End-to-End-Test gegen `_TestShell` schreiben** ```dart testWidgets( @@ -1285,15 +1416,92 @@ git commit -m "Vollbild-Player: Schließgeste live an progress gekoppelt, symmet 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** +- [ ] **Step 3: Integrationstest gegen das echte `HomeShell` schreiben (fehlschlagend, falls die Live-Reaktivität irgendwo doch nur pro-forma verdrahtet wäre)** -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. +```dart +// test/player/home_shell_real_expansion_test.dart +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/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'; +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'; -- [ ] **Step 4: Commit** +// Dieselbe _MemorySpeicher/Provider-Verdrahtung wie test/home_shell_test.dart +// — von dort kopieren, nicht neu erfinden. + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets( + 'Echtes HomeShell: Blur-Layer reagiert live auf progress, nicht erst ' + 'beim nächsten fremden Rebuild', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + // ... übriges Setup wie in test/home_shell_test.dart ... + await tester.pumpWidget(MultiProvider( + providers: [/* wie test/home_shell_test.dart */], + child: const MaterialApp(home: HomeShell()), + )); + await tester.pump(); + + expect(find.byType(BackdropFilter), findsNothing); + + final controllerFinder = find.byType(HomeShell); + final controller = + Provider.of( + tester.element(controllerFinder), listen: false); + controller.open(tester.element(controllerFinder)); + // Absichtlich NUR EINEN kurzen Zwischenschritt pumpen (nicht + // pumpAndSettle) — das ist der entscheidende Unterschied zum + // `_TestShell`-Test: er beweist, dass der Blur-Layer OHNE einen + // zusätzlichen, fremden setState()-Trigger (z.B. Tab-Wechsel) live + // erscheint. + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byType(BackdropFilter), findsOneWidget); + + await tester.pumpAndSettle(); + }); +} +``` + +- [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** + +Run: `flutter test test/player/home_shell_real_expansion_test.dart` +Expected: PASS. Schlägt er fehl, obwohl Tasks 1-8 wie spezifiziert umgesetzt wurden, liegt der Fehler im echten `lib/main.dart` (z.B. eine der drei `AnimatedBuilder`-Ebenen aus Task 4 wurde doch wieder als `child:` statt in `builder:` verdrahtet) — dort suchen, nicht den Test lockern. + +- [ ] **Step 5: State-Persistenz-Test schreiben (belegt den in der Spec beworbenen Vorteil)** + +Im selben File oder `test/player/now_playing_screen_test.dart` (je nachdem, wo `_CoverGrundState` einfacher erreichbar ist): ein Test, der über einen `open() → close() → open()`-Zyklus hinweg beweist, dass `NowPlayingScreen`/`_CoverGrund` NICHT neu erzeugt wird (z.B. per `GlobalKey` auf `_CoverGrund` und `tester.state(...)`-Identitätsvergleich vor/nach dem Zyklus, oder ein `Key` auf einem inneren Marker-Widget, das bei `initState` einen Zähler hochzählt — Zähler bleibt bei genau `1`, obwohl `open()`/`close()` mehrfach aufgerufen wurden). + +- [ ] **Step 6: Test laufen lassen, sicherstellen dass er besteht** + +Run: `flutter test --no-pub -t "State-Persistenz"` (oder passender Filter je nach gewähltem Testnamen) +Expected: PASS — bestätigt, dass die `Visibility(maintainState: true)`-Lösung aus Task 5 tatsächlich greift. + +- [ ] **Step 7: Spec-Abdeckung gegenprüfen** + +Gegen `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md` durchgehen: `PlayerExpansionController` ✓ (Task 1), Blur-Layer ✓ (Task 4, jetzt zusätzlich gegen das echte `HomeShell` verifiziert), `WanderndesCover` ✓ (Task 6), 4 Öffnen-Stellen ✓ (Task 3+7), Zurück-Taste ✓ (Task 2), symmetrische Schließgeste ✓ (Task 8), State-Persistenz-Vorteil ✓ (dieser Task, Step 5). Keine offenen Punkte. + +- [ ] **Step 8: Commit** ```bash -git add test/player/home_shell_expansion_test.dart -git commit -m "Integrationstest: kompletter Öffnen/Schließen-Zyklus über PlayerExpansionController" +git add test/player/ +git commit -m "Integrationstests: kompletter Zyklus gegen echtes HomeShell + State-Persistenz-Nachweis" ``` --- @@ -1303,16 +1511,25 @@ git commit -m "Integrationstest: kompletter Öffnen/Schließen-Zyklus über Play **Files:** - Modify: `CHANGELOG.md` -- [ ] **Step 1: CHANGELOG-Eintrag ergänzen** (oberster Eintrag, Format ✅/🔧/🐛 laut Projekt-Konvention, siehe bestehende Einträge) +- [ ] **Step 1: CHANGELOG-Eintrag ergänzen** — eigene `### Titel (Datum)`-Zwischenüberschrift direkt unter `## [Unreleased]`, oberhalb des bestehenden `EinblendItem`-Eintrags (Konvention: siehe dortiger Eintrag, nicht nur bare Bullets unter `## [Unreleased]`) ```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 +### ✨ Vollbild-Player: Live-Blur-Übergang statt fester Animation (2026-08-29) + +- ✅ **Teil 2 der Animations-Serie**: Übergang Mini-Player ↔ Vollbild-Player + hängt jetzt live an der Zugdistanz der Wischgeste (Blur + Cover-Wanderung), + nicht erst nach Abschluss einer festen Animation. Symmetrisch beim Öffnen + und Schließen. +- 🔧 `NowPlayingScreen` ist keine per `Navigator.push` geöffnete Route mehr, + sondern ein persistentes Overlay in `HomeShell` (`PlayerExpansionController`, + `lib/player/player_expansion_controller.dart`). Der bisherige + `Hero`-Cover-Übergang ist durch `WanderndesCover` ersetzt (Rect-Interpolation + statt Navigator-Flight). ``` -(Vor den bereits vorhandenen `## [Unreleased]`-Einträgen — nicht darüber eine zweite Überschrift anlegen, sondern in den bestehenden Abschnitt einsortieren.) +(Direkt unter `## [Unreleased]`, oberhalb des bestehenden `EinblendItem`-Eintrags einsortieren — nicht darüber eine zweite `## [Unreleased]`-Überschrift anlegen.) - [ ] **Step 2: Volle Verifikation** diff --git a/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md b/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md index cb4d454..202bf59 100644 --- a/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md +++ b/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md @@ -198,3 +198,27 @@ beobachtbarer Unterschied gegenüber heute. - `PlayerExpansionController` muss den `mounted`-Zustand von `HomeShell` respektieren (kein `notifyListeners()`/Controller-Zugriff nach `dispose()`) — analog zum bestehenden Muster in `_CoverGrundState`. + +## Nachtrag: Review-Panel (2026-08-29, vor Umsetzung) + +Ein Budget-Adversarial-Panel (3 Reviewer + Synthese) hat den Implementierungsplan +gegen diese Spec geprüft und dabei Lücken gefunden, die der Plan direkt behebt +(Details im Implementierungsplan-Dokument, nicht hier wiederholt): + +- Hit-Test- und Screenreader-Isolation zum Hintergrund waren in der ersten + Planfassung nicht vorgesehen — jetzt Teil von Task 4 (`IgnorePointer`/ + `ExcludeSemantics` auf Tab-Inhalt und `BottomNavigationBar` bei `progress > 0`). +- Der Wegfall der bisher über `Scaffold`/`AppBar` impliziten Status-Icon- + Helligkeit war nicht bedacht — jetzt Teil von Task 5 (`AnnotatedRegion`). +- Ohne `extendBody: true` hätte das "Vollbild"-Rechteck nicht zur tatsächlichen + `Scaffold.body`-Höhe gepasst — jetzt Teil von Task 4. +- Erwogene, aber verworfene Alternative: ein nur *während der Geste* + eingeblendeter `OverlayEntry` (Blur + Cover-Vorschau) direkt in + `mini_player.dart`, der bei Erreichen der Öffnen-Schwelle in einen normalen + `Navigator.push` mit unverändertem, Hero-basiertem `NowPlayingScreen` + übergeht — deutlich weniger Code, behält Route-Semantik (Accessibility- + Grenze, einfacheres Zurück-Verhalten) vollständig. Nicht gewählt, weil + Dustin sich für die Overlay-Variante (näher an Spotify/Apple Music, siehe + oben) entschieden hat — hier dokumentiert, damit die Abwägung nachvollziehbar + bleibt, falls der Wartungsaufwand des Overlay-Ansatzes sich später als zu + hoch erweist.