Plan/Spec: Review-Panel-Befunde eingearbeitet

Budget-Adversarial-Panel (3 Reviewer, unabhängig konvergent) fand einen P0
(AnimatedBuilder.child-Fehlgebrauch macht Blur-Layer nicht live) und mehrere
P1s (State-Persistenz-Widerspruch zur Spec, fehlende Hit-Test-/Accessibility-
Isolation, verworfene Loslass-Geschwindigkeit, fehlendes extendBody, Tests
nur gegen Test-Doppelgänger). Alle behoben: eigene AnimatedBuilder pro
reaktiver Ebene (Reaktivitäts-Regel in Global Constraints), Visibility statt
Conditional-Unmount, IgnorePointer/ExcludeSemantics für Hintergrund-Layer,
echte Fling-Geschwindigkeit, extendBody + BottomNavigationBar-Fade, neuer
Integrationstest gegen das echte HomeShell, State-Persistenz-Test.
This commit is contained in:
Hermes (Server)
2026-08-29 14:07:59 +02:00
parent 8f4c3fa56f
commit dbaec47faf
2 changed files with 302 additions and 61 deletions
@@ -17,6 +17,7 @@
- Reduce-Motion: jede neue Animation muss `MediaQuery.disableAnimationsOf(context)` respektieren (Sofortsprung statt Animation), analog zum bestehenden `MeloMotion.ruhig`-Muster. - 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`, …). - 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). - `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<PlayerExpansionController>`, 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/library/song_list.dart` | `Navigator.push(NowPlayingScreen)``controller.open(context)` |
| `lib/playlists/playlist_detail_screen.dart` | dito | | `lib/playlists/playlist_detail_screen.dart` | dito |
| `lib/downloads/server_titel_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 | | `CHANGELOG.md` | Eintrag |
--- ---
@@ -235,6 +237,7 @@ Expected: FAIL — `player_expansion_controller.dart` existiert nicht (Import-Fe
// lib/player/player_expansion_controller.dart // lib/player/player_expansion_controller.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../shared/theme.dart';
import 'wischen.dart'; import 'wischen.dart';
/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht — /// 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 /// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes
/// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv) /// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv)
/// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste /// 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) { void dragBy(double dy) {
_animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0); _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 /// [oeffnetVollbildMitStrecke] entschieden; bei einer Schließen-Geste
/// (strecke >= 0, im Vollbild-Player nach unten gezogen) über dieselbe /// (strecke >= 0, im Vollbild-Player nach unten gezogen) über dieselbe
/// Funktion mit gespiegeltem Vorzeichen — "stark genug nach unten" ist /// 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) { void dragEnd(BuildContext context, double strecke, double geschwindigkeit) {
final zielIstOffen = strecke <= 0 final zielIstOffen = strecke <= 0
? oeffnetVollbildMitStrecke(strecke, geschwindigkeit) ? oeffnetVollbildMitStrecke(strecke, geschwindigkeit)
: !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)) { if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = ziel; _animation.value = ziel;
return; return;
} }
_animation.fling(velocity: aufwaerts ? 1 : -1); _animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve);
} }
@override @override
@@ -435,17 +457,25 @@ class _HomeShellState extends State<HomeShell>
```dart ```dart
return ChangeNotifierProvider<PlayerExpansionController>.value( return ChangeNotifierProvider<PlayerExpansionController>.value(
value: _expansion, value: _expansion,
child: AnimatedBuilder( // Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird
animation: _expansion, // laut Flutter-Semantik bei jedem _expansion-Tick NICHT neu gebaut,
builder: (context, child) => PopScope( // 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<PlayerExpansionController>(
builder: (context, expansion, child) => PopScope(
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset: // Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch // Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
// den Tab wechseln. // den Tab wechseln.
canPop: _expansion.progress == 0 && _index == 0, canPop: expansion.progress == 0 && _index == 0,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) return; if (didPop) return;
if (_expansion.progress > 0) { if (expansion.progress > 0) {
_expansion.close(context); expansion.close(context);
} else { } else {
setState(() => _index = 0); 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<PlayerExpansionController>().open(context)`. Fehlerpfad/Snackbar-Logik bleibt unverändert. Alle drei Stellen folgen demselben Muster: nach erfolgreichem Abspielen `Navigator.push(MaterialPageRoute(builder: (_) => const NowPlayingScreen()))``context.read<PlayerExpansionController>().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** - [ ] **Step 1: `song_list.dart` ändern**
Vorher (Zeile ~382-385): 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); expect(find.byType(BackdropFilter), findsOneWidget);
await tester.pumpAndSettle(); 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** - [ ] **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 ```dart
extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar
body: Stack( body: Stack(
children: [ children: [
Column( AnimatedBuilder(
children: [ animation: _expansion,
Expanded(child: IndexedStack(index: _index, children: tabs)), builder: (context, child) => IgnorePointer(
const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player 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) AnimatedBuilder(
Positioned.fill( animation: _expansion,
child: BackdropFilter( builder: (context, child) {
filter: ImageFilter.blur( final p = _expansion.progress;
sigmaX: _expansion.progress * 20.0, if (p == 0) return const SizedBox.shrink();
sigmaY: _expansion.progress * 20.0, 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. // NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier.
], ],
@@ -641,7 +713,24 @@ Nachher:
``` ```
Import ergänzen: `import 'dart:ui';` (für `ImageFilter`). 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** - [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht**
@@ -696,7 +785,14 @@ class NowPlayingScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
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<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: _CoverGrund(
handler: handler, handler: handler,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
@@ -754,17 +850,25 @@ class NowPlayingScreen extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }
/// Platzhalter im Layoutdas eigentliche Cover rendert `_WanderndesCover` /// Schlüssel des Cover-PlatzhaltersTask 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 /// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom
/// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann. /// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann.
class _CoverPlatzhalter extends StatelessWidget { class _CoverPlatzhalter extends StatelessWidget {
const _CoverPlatzhalter(); const _CoverPlatzhalter();
@override @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 /// 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** - [ ] **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 ```dart
AnimatedBuilder( AnimatedBuilder(
animation: _expansion, animation: _expansion,
builder: (context, child) { builder: (context, child) {
final p = _expansion.progress; final p = _expansion.progress;
if (p == 0) return const SizedBox.shrink();
return Positioned.fromRect( return Positioned.fromRect(
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!, rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
child: Opacity( child: Visibility(
// Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec. visible: p > 0,
opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0), maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec
child: const NowPlayingScreen(), 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`: Mit zwei kleinen Hilfsmethoden in `_HomeShellState`:
```dart ```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** - [ ] **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 ### Task 9: Integrationstest über den ganzen Zyklus + Spec-Abdeckung prüfen
**Files:** **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 28) 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 ```dart
testWidgets( 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` 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) 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<PlayerExpansionController>(
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 ```bash
git add test/player/home_shell_expansion_test.dart git add test/player/
git commit -m "Integrationstest: kompletter Öffnen/Schließen-Zyklus über PlayerExpansionController" 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:** **Files:**
- Modify: `CHANGELOG.md` - 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 ```markdown
## [Unreleased] ## [Unreleased]
Vollbild-Player öffnet/schließt jetzt live mit der Wischgeste (Blur + Cover-Wanderung), nicht erst nach fester Animation ### ✨ Vollbild-Player: Live-Blur-Übergang statt fester Animation (2026-08-29)
🔧 NowPlayingScreen ist kein Navigator-Route mehr, sondern persistentes Overlay in HomeShell (PlayerExpansionController); Hero-Cover-Übergang durch WanderndesCover ersetzt
-**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** - [ ] **Step 2: Volle Verifikation**
@@ -198,3 +198,27 @@ beobachtbarer Unterschied gegenüber heute.
- `PlayerExpansionController` muss den `mounted`-Zustand von `HomeShell` - `PlayerExpansionController` muss den `mounted`-Zustand von `HomeShell`
respektieren (kein `notifyListeners()`/Controller-Zugriff nach `dispose()`) respektieren (kein `notifyListeners()`/Controller-Zugriff nach `dispose()`)
— analog zum bestehenden Muster in `_CoverGrundState`. — 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.