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:
@@ -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<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/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<HomeShell>
|
||||
```dart
|
||||
return ChangeNotifierProvider<PlayerExpansionController>.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<PlayerExpansionController>(
|
||||
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<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**
|
||||
|
||||
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,27 +657,43 @@ 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(
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
builder: (context, child) => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
@@ -631,17 +701,36 @@ Nachher:
|
||||
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
||||
child: IgnorePointer(
|
||||
ignoring: _expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const MiniPlayer(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier.
|
||||
],
|
||||
),
|
||||
```
|
||||
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<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,
|
||||
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: 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: const NowPlayingScreen(),
|
||||
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<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
|
||||
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**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user