Files
pyramid/test/spring_motion_test.dart
Bernd SteckmeisterandClaude Opus 5.5 ae6f651f4c feat: federnde Übergänge und Animationen, Entwürfe je Raum
- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut,
  Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während
  des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein.
- Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen
  oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen,
  Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer),
  Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht
  zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus).
- Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am
  neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt
  eigenes Eingabefeld je Raum mit Entwurf im Speicher.
- Antwort-Leiste: größeres Schließen-X auf dem Handy.
- Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:07:17 +02:00

151 lines
5.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart';
/// Handy-Übergang Chatliste ↔ Chat und die federnden Einblendungen.
void main() {
Widget host({required Widget? front, double motion = 1}) {
return PyramidTheme(
motion: motion,
child: Directionality(
textDirection: TextDirection.ltr,
child: MobilePageStack(
base: const _Counter(key: ValueKey('liste')),
front: front,
background: const Color(0xFF101010),
),
),
);
}
testWidgets('Chatliste behält ihren Zustand, Chat gleitet herein und hinaus',
(tester) async {
await tester.pumpWidget(host(front: null));
await tester.tap(find.byKey(const ValueKey('liste')));
await tester.pump();
expect(find.text('Zähler 1'), findsOneWidget);
// Chat öffnen: gleitet von rechts herein.
await tester.pumpWidget(host(front: const Text('Chat')));
await tester.pump(const Duration(milliseconds: 40));
final midX = tester.getTopLeft(find.text('Chat')).dx;
expect(midX, greaterThan(0), reason: 'Chat sollte noch hereingleiten');
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
// Verdeckte Liste ist nicht mehr bedienbar, aber noch aufgebaut.
expect(find.text('Zähler 1', skipOffstage: false), findsOneWidget);
// Zurück: Chat gleitet hinaus und wird danach entfernt.
await tester.pumpWidget(host(front: null));
await tester.pump(const Duration(milliseconds: 40));
expect(find.text('Chat'), findsOneWidget, reason: 'noch beim Hinausgleiten');
// Schon während des Hinausgleitens ist die Liste wieder bedienbar.
await tester.tapAt(const Offset(20, 300));
await tester.pumpAndSettle();
expect(find.text('Zähler 2'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Chat'), findsNothing);
// Zustand der Liste ist erhalten geblieben.
await tester.tap(find.byKey(const ValueKey('liste')));
await tester.pump();
expect(find.text('Zähler 3'), findsOneWidget);
});
testWidgets('Animationen 0 %: sofortiger Wechsel ohne Gleiten', (
tester,
) async {
await tester.pumpWidget(host(front: null, motion: 0));
await tester.pumpWidget(host(front: const Text('Chat'), motion: 0));
await tester.pump();
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
await tester.pumpWidget(host(front: null, motion: 0));
await tester.pump();
expect(find.text('Chat'), findsNothing);
});
testWidgets('PopIn spielt nur, wenn verlangt, und endet unverändert', (
tester,
) async {
Widget pop(bool play) => PyramidTheme(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: PopIn(play: play, child: const Text('Neu')),
),
),
);
await tester.pumpWidget(pop(true));
await tester.pump(const Duration(milliseconds: 30));
final opacity = tester.widget<Opacity>(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
);
expect(opacity.opacity, lessThan(1));
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
)
.opacity,
1,
);
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(pop(false));
expect(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
findsNothing,
);
});
testWidgets('SpringBump hüpft nur beim Hochzählen', (tester) async {
Widget bump(int v) => PyramidTheme(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(child: SpringBump(value: v, child: Text('$v'))),
),
);
double scale() => tester
.widget<Transform>(
find.ancestor(
of: find.byType(Text),
matching: find.byType(Transform),
),
)
.transform
.storage[0]; // x-Skalierung (z bleibt bei Transform.scale immer 1)
await tester.pumpWidget(bump(1));
expect(scale(), 1);
await tester.pumpWidget(bump(2));
await tester.pump(const Duration(milliseconds: 16));
expect(scale(), lessThan(1), reason: 'Hochzählen soll hüpfen');
await tester.pumpAndSettle();
expect(scale(), closeTo(1, 0.001));
await tester.pumpWidget(bump(0));
await tester.pump(const Duration(milliseconds: 16));
expect(scale(), closeTo(1, 0.001), reason: 'Herunterzählen hüpft nicht');
});
}
class _Counter extends StatefulWidget {
const _Counter({super.key});
@override
State<_Counter> createState() => _CounterState();
}
class _CounterState extends State<_Counter> {
int _n = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _n++),
child: Center(child: Text('Zähler $_n')),
);
}