fix(android): intercept back during cold startup and move exit toast down

This commit is contained in:
Levi committed 2026-09-12 20:40:07 +08:00
1 parent ee6b073bc1
commit bd03d3b31c
8 files changed
+195 -48

No files matched your search

@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/app/router/app_router.dart';
import 'package:gopeed/features/auth/application/web_auth_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;
class _StartingRuntime extends AppRuntimeController {
final pending = Completer<AppRuntimeState>();
@override
Future<AppRuntimeState> build() => pending.future;
}
void main() {
testWidgets(
'cold startup registers Android back and confirms exit before runtime is ready',
(tester) async {
final platformCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
platformCalls.add(call);
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
final runtime = _StartingRuntime();
final auth = WebAuthController();
final router = AppRouter.build(auth);
addTearDown(router.dispose);
addTearDown(auth.dispose);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
ProviderScope(
overrides: [appRuntimeControllerProvider.overrideWith(() => runtime)],
child: shad.ShadcnApp.router(
theme: AppTheme.dark(),
materialTheme: AppTheme.materialDark(),
routerConfig: router,
),
),
);
await tester.pump();
await tester.pump();
expect(find.byType(shad.CircularProgressIndicator), findsOneWidget);
expect(
platformCalls.where((call) => call.method == 'SystemNavigator.setFrameworkHandlesBack').last.arguments,
isTrue,
);
await tester.binding.handlePopRoute();
await tester.pump();
expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), isEmpty);
expect(find.byType(AppToastContent), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pump();
expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), hasLength(1));
await tester.pumpWidget(const SizedBox());
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
@@ -24,11 +24,11 @@ void main() {
router = GoRouter(
routes: [
ShellRoute(
builder: (_, _, child) => child,
builder: (_, _, child) => MobileExitGuard(child: child),
routes: [
GoRoute(
path: '/',
builder: (_, _) => const MobileExitGuard(child: SizedBox.expand()),
builder: (_, _) => const SizedBox.expand(),
routes: [
GoRoute(path: 'details', builder: (_, _) => const SizedBox.expand()),
for (final path in ['extensions', 'settings'])
@@ -56,13 +56,14 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('root back shows centered prompt, second back exits', (tester) async {
testWidgets('root back shows bottom 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));
expect(tester.getCenter(find.byType(AppToastContent)).dx, 400);
expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, 576);
await tester.pump(const Duration(milliseconds: 1900));
await tester.binding.handlePopRoute();
await tester.pump();
@@ -127,4 +128,34 @@ void main() {
expect(exits, hasLength(1));
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
}
testWidgets(
'dialog back dismisses the dialog before exit confirmation',
(tester) async {
await mount(tester);
final context = router.routerDelegate.navigatorKey.currentContext!;
showGeneralDialog<void>(
context: context,
pageBuilder: (_, _, _) => const Center(child: Text('Dialog content')),
);
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Dialog content'), findsNothing);
expect(find.byType(AppToastContent), findsNothing);
expect(exits, isEmpty);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets('bottom prompt stays above the Android safe area', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 48);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetDevicePixelRatio);
await mount(tester);
await tester.binding.handlePopRoute();
await tester.pump();
expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, tester.view.physicalSize.height - 48 - 24);
await tester.pumpWidget(const SizedBox());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
}