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.
|
- 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,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
|
```dart
|
||||||
|
extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Column(
|
AnimatedBuilder(
|
||||||
|
animation: _expansion,
|
||||||
|
builder: (context, child) => IgnorePointer(
|
||||||
|
ignoring: _expansion.progress > 0,
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
excluding: _expansion.progress > 0,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: IndexedStack(index: _index, children: tabs)),
|
Expanded(child: IndexedStack(index: _index, children: tabs)),
|
||||||
const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player
|
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),
|
child: Container(color: Colors.transparent),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Positioned(
|
AnimatedBuilder(
|
||||||
|
animation: _expansion,
|
||||||
|
builder: (context, child) => Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -631,17 +701,36 @@ Nachher:
|
|||||||
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: _expansion.progress > 0,
|
ignoring: _expansion.progress > 0,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: const MiniPlayer(),
|
child: const MiniPlayer(),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
// NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier.
|
// NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier.
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
```
|
```
|
||||||
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 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
|
/// (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: Visibility(
|
||||||
|
visible: p > 0,
|
||||||
|
maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec
|
||||||
|
maintainAnimation: true,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
// Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec.
|
// Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec.
|
||||||
opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0),
|
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`:
|
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 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
|
```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.
|
||||||
|
|||||||
Reference in New Issue
Block a user