PlayerExpansionController: optischer Öffnen-Fortschritt für den Vollbild-Player
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user