Merge mobile back navigation and exit confirmation into beta

This commit is contained in:
Levi committed 2026-09-12 19:03:24 +08:00
commit d330d8bea8
7 files changed
+307 -66

No files matched your search

@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/app/router/app_router.dart';
import 'package:gopeed/features/auth/application/web_auth_controller.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final platform in TargetPlatform.values) {
test('$platform preserves the expected section route stacks', () {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final auth = WebAuthController();
final router = AppRouter.build(auth);
addTearDown(router.dispose);
addTearDown(auth.dispose);
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
for (final entry in {
'/': ['/'],
'/extensions': isMobile ? ['/', 'extensions'] : ['/extensions'],
'/extensions/example': isMobile ? ['/', 'extensions', ':id'] : ['/extensions', ':id'],
'/settings': isMobile ? ['/', 'settings'] : ['/settings'],
'/settings/general': isMobile ? ['/', 'settings', ':section'] : ['/settings', ':section'],
'/tasks/example': ['/', 'tasks/:id'],
'/create': ['/', 'create'],
}.entries) {
final match = router.configuration.findMatch(Uri.parse(entry.key));
expect(match.isError, isFalse);
final shell = match.matches.single as ShellRouteMatch;
expect(shell.matches.map((match) => (match.route as GoRoute).path), entry.value);
}
});
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/app/router/mobile_exit_guard.dart';
import 'package:gopeed/shared/navigation/app_exit_confirmation_controller.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_toast.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
late List<MethodCall> exits;
late GoRouter router;
setUp(() {
exits = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'SystemNavigator.pop') exits.add(call);
return null;
},
);
router = GoRouter(
routes: [
ShellRoute(
builder: (_, _, child) => child,
routes: [
GoRoute(
path: '/',
builder: (_, _) => const MobileExitGuard(child: SizedBox.expand()),
routes: [
GoRoute(path: 'details', builder: (_, _) => const SizedBox.expand()),
for (final path in ['extensions', 'settings'])
GoRoute(path: path, builder: (_, _) => const SizedBox.expand()),
],
),
],
),
],
);
});
tearDown(() {
router.dispose();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
Future<void> mount(WidgetTester tester) async {
await tester.pumpWidget(
shad.ShadcnApp.router(theme: AppTheme.dark(), materialTheme: AppTheme.materialDark(), routerConfig: router),
);
await tester.pumpAndSettle();
}
testWidgets('root back shows centered prompt, second back exits', (tester) async {
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
expect(tester.getCenter(find.byType(AppToastContent)), const Offset(400, 300));
await tester.pump(const Duration(milliseconds: 1900));
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, hasLength(1));
expect(find.byType(AppToastContent), findsNothing);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('expired prompt requires a fresh pair of back actions', (tester) async {
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
await tester.pump(AppExitConfirmationController.confirmationWindow);
expect(find.byType(AppToastContent), findsNothing);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
await tester.pumpWidget(const SizedBox());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('nested page pops without prompting or exiting', (tester) async {
await mount(tester);
router.push('/details');
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(router.routeInformationProvider.value.uri.path, '/');
expect(find.byType(AppToastContent), findsNothing);
expect(exits, isEmpty);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('backgrounding clears the exit confirmation', (tester) async {
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(find.byType(AppToastContent), findsNothing);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
await tester.pumpWidget(const SizedBox());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
for (final path in ['/extensions', '/settings']) {
testWidgets('$path returns to tasks before confirming exit', (tester) async {
await mount(tester);
router.go(path);
await tester.pumpAndSettle();
expect(router.canPop(), isTrue);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(router.routeInformationProvider.value.uri.path, '/');
expect(find.byType(AppToastContent), findsNothing);
expect(exits, isEmpty);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byType(AppToastContent), findsOneWidget);
expect(exits, isEmpty);
await tester.binding.handlePopRoute();
await tester.pump();
expect(exits, hasLength(1));
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
}
}