217 lines
7.0 KiB
Dart
217 lines
7.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:marianum_mobile/widget/route_transition_gate.dart';
|
|
|
|
void main() {
|
|
const gated = Key('gated-child');
|
|
const placeholder = Key('gate-placeholder');
|
|
|
|
Widget gatedPage() => Scaffold(
|
|
body: RouteTransitionGate(
|
|
placeholder: const SizedBox(key: placeholder),
|
|
// Hit-testable like the real viewer, so pointer tracking sees it.
|
|
builder: (_) => const ColoredBox(
|
|
key: gated,
|
|
color: Color(0x00000000),
|
|
child: SizedBox.expand(),
|
|
),
|
|
),
|
|
);
|
|
|
|
Future<void> pumpApp(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: Builder(builder: (_) => const Scaffold())),
|
|
);
|
|
}
|
|
|
|
NavigatorState navigator(WidgetTester tester) =>
|
|
tester.state<NavigatorState>(find.byType(Navigator));
|
|
|
|
testWidgets('shows placeholder during push, child once settled', (
|
|
tester,
|
|
) async {
|
|
await pumpApp(tester);
|
|
unawaited(
|
|
navigator(
|
|
tester,
|
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
expect(find.byKey(placeholder), findsOneWidget);
|
|
expect(find.byKey(gated), findsNothing);
|
|
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
expect(find.byKey(placeholder), findsNothing);
|
|
});
|
|
|
|
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
|
await pumpApp(tester);
|
|
unawaited(
|
|
navigator(
|
|
tester,
|
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
|
|
navigator(tester).pop();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
expect(find.byKey(gated), findsNothing);
|
|
expect(find.byKey(placeholder), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('gates during a secondary transition and recovers after pop', (
|
|
tester,
|
|
) async {
|
|
await pumpApp(tester);
|
|
unawaited(
|
|
navigator(
|
|
tester,
|
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
|
|
unawaited(
|
|
navigator(
|
|
tester,
|
|
).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
// Mid secondary transition the fragile subtree must be unmounted.
|
|
expect(find.byKey(gated), findsNothing);
|
|
|
|
await tester.pumpAndSettle();
|
|
navigator(tester).pop();
|
|
await tester.pumpAndSettle();
|
|
// Back at rest on the gated route: child is mounted again.
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
expect(find.byKey(placeholder), findsNothing);
|
|
});
|
|
|
|
testWidgets('re-gates when the settled subtree moves under a fresh Transform', (
|
|
tester,
|
|
) async {
|
|
// Android's predictive back moves the route content (GlobalKey) under a
|
|
// brand-new Transform.scale without any animation status change. The
|
|
// padding changes the constraints so the moved subtree is laid out nested
|
|
// under the transform's first layout, which is when `localToGlobal` throws.
|
|
final subtree = GlobalKey();
|
|
var wrapped = false;
|
|
late StateSetter rebuild;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatefulBuilder(
|
|
builder: (context, setState) {
|
|
rebuild = setState;
|
|
final child = KeyedSubtree(
|
|
key: subtree,
|
|
child: Scaffold(
|
|
body: RouteTransitionGate(
|
|
placeholder: const SizedBox(key: placeholder),
|
|
builder: (_) => const _LocalToGlobalProbe(key: gated),
|
|
),
|
|
),
|
|
);
|
|
if (!wrapped) return child;
|
|
return Padding(
|
|
padding: const EdgeInsets.all(1),
|
|
child: Transform.scale(scale: 1, child: child),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
|
|
rebuild(() => wrapped = true);
|
|
await tester.pump();
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.byKey(placeholder), findsOneWidget);
|
|
|
|
await tester.pump();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('keeps the child mounted while a back swipe drives the pop', (
|
|
tester,
|
|
) async {
|
|
// iOS back swipe: the edge detector is translucent, so the finger that
|
|
// drives the pop animation is also down on the viewer. Unmounting it now
|
|
// would route the next pointer move into a disposed State. (A programmatic
|
|
// pop is harmless — the Navigator cancels active pointers first.)
|
|
await pumpApp(tester);
|
|
unawaited(
|
|
navigator(
|
|
tester,
|
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
|
|
final gesture = await tester.startGesture(const Offset(5, 300));
|
|
await gesture.moveBy(const Offset(60, 0));
|
|
await tester.pump();
|
|
await gesture.moveBy(const Offset(60, 0));
|
|
await tester.pump();
|
|
// Pop animation is running (status reverse), finger still down.
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
expect(find.byKey(placeholder), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(find.byKey(gated), findsNothing);
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
|
|
|
|
testWidgets('holds the child back for one frame without a route animation', (
|
|
tester,
|
|
) async {
|
|
// The tab case: no route animation ever runs, but the ancestors (and any
|
|
// freshly inserted tab-transition Transform) do their first layout in this
|
|
// very frame, so the fragile subtree must not be part of it.
|
|
await tester.pumpWidget(MaterialApp(home: gatedPage()));
|
|
expect(find.byKey(placeholder), findsOneWidget);
|
|
expect(find.byKey(gated), findsNothing);
|
|
|
|
await tester.pump();
|
|
expect(find.byKey(gated), findsOneWidget);
|
|
expect(find.byKey(placeholder), findsNothing);
|
|
});
|
|
}
|
|
|
|
/// Mimics SfPdfViewer: a stateful widget whose LayoutBuilder resolves a
|
|
/// child's global position during layout.
|
|
class _LocalToGlobalProbe extends StatefulWidget {
|
|
const _LocalToGlobalProbe({super.key});
|
|
|
|
@override
|
|
State<_LocalToGlobalProbe> createState() => _LocalToGlobalProbeState();
|
|
}
|
|
|
|
class _LocalToGlobalProbeState extends State<_LocalToGlobalProbe> {
|
|
final _viewport = GlobalKey();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final box = _viewport.currentContext?.findRenderObject() as RenderBox?;
|
|
if (box != null && box.hasSize) box.localToGlobal(Offset.zero);
|
|
return SizedBox.expand(key: _viewport);
|
|
},
|
|
);
|
|
}
|