diff --git a/lib/player/player_expansion_controller.dart b/lib/player/player_expansion_controller.dart new file mode 100644 index 0000000..82fcae4 --- /dev/null +++ b/lib/player/player_expansion_controller.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../shared/theme.dart'; +import 'wischen.dart'; + +/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht — +/// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in +/// [wischen.dart]. +const oeffnenReferenzHoehe = 300.0; + +/// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1). +/// +/// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert +/// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang +/// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt). +class PlayerExpansionController extends ChangeNotifier { + PlayerExpansionController({required TickerProvider vsync}) + : _animation = AnimationController(vsync: vsync) { + _animation.addListener(notifyListeners); + } + + final AnimationController _animation; + + double get progress => _animation.value; + + /// [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. 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); + } + + /// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben). + /// Bei einer Öffnen-Geste (strecke <= 0) direkt über + /// [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". 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); + 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) => _animiereZu(context, 1.0); + + void close(BuildContext context) => _animiereZu(context, 0.0); + + /// 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.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve); + } + + @override + void dispose() { + _animation.dispose(); + super.dispose(); + } +} diff --git a/test/player/player_expansion_controller_test.dart b/test/player/player_expansion_controller_test.dart new file mode 100644 index 0000000..e83a96a --- /dev/null +++ b/test/player/player_expansion_controller_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/player_expansion_controller.dart'; + +void main() { + Widget mitMediaQuery(bool disableAnimations, WidgetBuilder builder) { + return MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: MaterialApp(home: Builder(builder: builder)), + ); + } + + testWidgets('progress startet bei 0', (tester) async { + late PlayerExpansionController controller; + await tester.pumpWidget(mitMediaQuery(false, (context) { + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets('dragBy nach oben erhöht progress, nach unten verringert ihn', + (tester) async { + late PlayerExpansionController controller; + await tester.pumpWidget(mitMediaQuery(false, (context) { + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-150); // halbe Referenzhöhe nach oben + expect(controller.progress, closeTo(0.5, 0.001)); + + controller.dragBy(75); // ein Viertel zurück nach unten + expect(controller.progress, closeTo(0.25, 0.001)); + controller.dispose(); + }); + + testWidgets('dragBy clampt auf [0, 1]', (tester) async { + late PlayerExpansionController controller; + await tester.pumpWidget(mitMediaQuery(false, (context) { + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-1000); + expect(controller.progress, 1.0); + controller.dragBy(2000); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets('open() fliegt zu 1, close() fliegt zu 0', (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + + controller.close(ctx); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets('open()/close() springen bei disableAnimations sofort', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(true, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + expect(controller.progress, 1.0); // ohne pumpAndSettle sofort da + controller.close(ctx); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Öffnen: ausreichende Strecke nach oben fliegt zu 1', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-200); + controller.dragEnd(ctx, -200, 0); // Strecke > wischStreckeSchwelle (80) + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Öffnen: zu kurze, langsame Geste fällt zurück auf 0', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.dragBy(-30); + controller.dragEnd(ctx, -30, 0); // unter wischStreckeSchwelle + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Schließen: ausreichende Strecke nach unten fliegt zu 0', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + await tester.pumpAndSettle(); + controller.dragBy(200); // nach unten, Betrag > Referenzhöhe/2 + controller.dragEnd(ctx, 200, 0); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + controller.dispose(); + }); + + testWidgets( + 'dragEnd beim Schließen: zu kurze, langsame Geste bleibt bei 1', + (tester) async { + late PlayerExpansionController controller; + late BuildContext ctx; + await tester.pumpWidget(mitMediaQuery(false, (context) { + ctx = context; + controller = PlayerExpansionController(vsync: tester); + return const SizedBox(); + })); + + controller.open(ctx); + await tester.pumpAndSettle(); + controller.dragBy(30); + controller.dragEnd(ctx, 30, 0); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + controller.dispose(); + }); +}