HomeShell: PlayerExpansionController bereitstellen, Zurück-Taste schließt offenen Player
This commit is contained in:
+65
-40
@@ -19,6 +19,7 @@ import 'library/search_screen.dart';
|
||||
import 'player/audio_effects.dart';
|
||||
import 'player/audio_handler.dart';
|
||||
import 'player/mini_player.dart';
|
||||
import 'player/player_expansion_controller.dart';
|
||||
import 'services/baka_auth.dart';
|
||||
import 'services/logger_service.dart';
|
||||
import 'services/download_service.dart';
|
||||
@@ -150,9 +151,11 @@ class HomeShell extends StatefulWidget {
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
class _HomeShellState extends State<HomeShell>
|
||||
with WidgetsBindingObserver, TickerProviderStateMixin {
|
||||
int _index = 0;
|
||||
late final AutoScan _autoScan;
|
||||
late final PlayerExpansionController _expansion;
|
||||
|
||||
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
|
||||
String? _geteilteAdresse;
|
||||
@@ -162,6 +165,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expansion = PlayerExpansionController(vsync: this);
|
||||
final db = context.read<MeloDb>();
|
||||
final lib = context.read<LibraryService>();
|
||||
_autoScan = AutoScan(
|
||||
@@ -190,6 +194,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expansion.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -222,47 +227,67 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
const FavoritesScreen(),
|
||||
const YoutubeSearchScreen(),
|
||||
];
|
||||
return PopScope(
|
||||
// Tab-Wechsel laufen nur über setState, nie über Navigator.push — die
|
||||
// Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum
|
||||
// Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen.
|
||||
canPop: _index == 0,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) setState(() => _index = 0);
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: IndexedStack(index: _index, children: tabs)),
|
||||
const MiniPlayer(),
|
||||
],
|
||||
return ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: _expansion,
|
||||
// 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,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (expansion.progress > 0) {
|
||||
expansion.close(context);
|
||||
} else {
|
||||
setState(() => _index = 0);
|
||||
}
|
||||
},
|
||||
child: child!,
|
||||
),
|
||||
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
||||
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
|
||||
bottomNavigationBar: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: MeloTheme.border)),
|
||||
child: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: IndexedStack(index: _index, children: tabs)),
|
||||
const MiniPlayer(),
|
||||
],
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _index,
|
||||
onTap: _goTo,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.headphones), label: 'Meine Musik'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.download_outlined),
|
||||
activeIcon: Icon(Icons.download),
|
||||
label: 'Download'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.favorite_border),
|
||||
activeIcon: Icon(Icons.favorite),
|
||||
label: 'Favoriten'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.smart_display_outlined),
|
||||
activeIcon: Icon(Icons.smart_display),
|
||||
label: 'YT-Suche'),
|
||||
],
|
||||
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
||||
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
|
||||
bottomNavigationBar: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: MeloTheme.border)),
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _index,
|
||||
onTap: _goTo,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.headphones), label: 'Meine Musik'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.download_outlined),
|
||||
activeIcon: Icon(Icons.download),
|
||||
label: 'Download'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.favorite_border),
|
||||
activeIcon: Icon(Icons.favorite),
|
||||
label: 'Favoriten'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.smart_display_outlined),
|
||||
activeIcon: Icon(Icons.smart_display),
|
||||
label: 'YT-Suche'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:melo/player/player_expansion_controller.dart';
|
||||
|
||||
// Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell
|
||||
// nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen.
|
||||
// Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet,
|
||||
// sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test
|
||||
// sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double.
|
||||
class _TestShell extends StatefulWidget {
|
||||
const _TestShell();
|
||||
@override
|
||||
State<_TestShell> createState() => _TestShellState();
|
||||
}
|
||||
|
||||
class _TestShellState extends State<_TestShell> with TickerProviderStateMixin {
|
||||
late final PlayerExpansionController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = PlayerExpansionController(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Eigener AnimatedBuilder um PopScope, damit canPop bei jedem
|
||||
// _controller-Tick neu ausgewertet wird (Reaktivitäts-Regel aus dem
|
||||
// Plan) — ohne ihn bliebe canPop auf dem Wert des allerersten Builds
|
||||
// eingefroren, weil sonst nichts hier ein Rebuild auslöst.
|
||||
return ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: _controller,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) => PopScope(
|
||||
canPop: _controller.progress == 0,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _controller.close(context);
|
||||
},
|
||||
child: child!,
|
||||
),
|
||||
child: const Scaffold(body: SizedBox()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
|
||||
final controller =
|
||||
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
|
||||
|
||||
controller.open(tester.element(find.byType(_TestShell)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.progress, 1.0);
|
||||
|
||||
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
|
||||
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
|
||||
// Navigator.maybePop() löst denselben PopScope-Callback aus.
|
||||
await Navigator.maybePop(tester.element(find.byType(_TestShell)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.progress, 0.0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user