From bd03d3b31c477289f1bcd248074d4b025d559c4c Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 20:40:07 +0800 Subject: [PATCH] fix(android): intercept back during cold startup and move exit toast down --- ui/flutter/lib/app/router/app_router.dart | 41 +++++++----- .../lib/app/router/mobile_exit_guard.dart | 14 +++- ui/flutter/lib/app/router/runtime_page.dart | 30 +++++++++ .../lib/app/router/shells/main_shell.dart | 27 ++------ .../app_exit_confirmation_controller.dart | 11 ++-- .../app/router/app_startup_back_test.dart | 65 +++++++++++++++++++ .../app/router/mobile_exit_guard_test.dart | 39 +++++++++-- ui/flutter/test/widget_test.dart | 16 ++++- 8 files changed, 195 insertions(+), 48 deletions(-) create mode 100644 ui/flutter/lib/app/router/runtime_page.dart create mode 100644 ui/flutter/test/app/router/app_startup_back_test.dart diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 78007c3c..c1d7ee62 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -17,6 +17,7 @@ import '../../features/tasks/presentation/pages/task_details_page.dart'; import '../../features/tasks/presentation/pages/task_files_page.dart'; import 'shells/main_shell.dart'; import 'mobile_exit_guard.dart'; +import 'runtime_page.dart'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); @@ -35,24 +36,26 @@ class AppRouter { final sectionRoutes = [ GoRoute( path: isMobile ? 'extensions' : '/extensions', - builder: (context, state) => const ExtensionsPage(), + builder: _runtimePage((context, state) => const ExtensionsPage()), routes: [ GoRoute( path: ':id', - builder: (context, state) => ExtensionDetailsPage( - extensionId: state.pathParameters['id'] ?? '', - initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, + builder: _runtimePage( + (context, state) => ExtensionDetailsPage( + extensionId: state.pathParameters['id'] ?? '', + initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, + ), ), ), ], ), GoRoute( path: isMobile ? 'settings' : '/settings', - builder: (context, state) => const SettingsPage(), + builder: _runtimePage((context, state) => const SettingsPage()), routes: [ GoRoute( path: ':section', - builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), + builder: _runtimePage((context, state) => SettingsPage(sectionKey: state.pathParameters['section'])), ), ], ), @@ -74,25 +77,29 @@ class AppRouter { routes: [ GoRoute(path: '/login', parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const LoginPage()), ShellRoute( - builder: (context, state, child) => MainShell(child: child), + builder: (context, state, child) => MobileExitGuard(child: MainShell(child: child)), routes: [ GoRoute( path: '/', - builder: (context, state) => const MobileExitGuard(child: HomePage()), + builder: _runtimePage((context, state) => const HomePage()), routes: [ - GoRoute(path: 'create', builder: (context, state) => const CreateTaskWindowPage()), + GoRoute(path: 'create', builder: _runtimePage((context, state) => const CreateTaskWindowPage())), GoRoute( path: 'tasks/:id', - builder: (context, state) => TaskDetailsPage( - taskId: state.pathParameters['id'] ?? '', - initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + builder: _runtimePage( + (context, state) => TaskDetailsPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), ), routes: [ GoRoute( path: 'files', - builder: (context, state) => TaskFilesPage( - taskId: state.pathParameters['id'] ?? '', - initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + builder: _runtimePage( + (context, state) => TaskFilesPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), ), ), ], @@ -107,6 +114,10 @@ class AppRouter { ); } + static GoRouterWidgetBuilder _runtimePage(GoRouterWidgetBuilder builder) { + return (context, state) => RuntimePage(builder: (context) => builder(context, state)); + } + static String _safeReturnLocation(String? location) { if (location == null || location.isEmpty) return '/'; final uri = Uri.tryParse(location); diff --git a/ui/flutter/lib/app/router/mobile_exit_guard.dart b/ui/flutter/lib/app/router/mobile_exit_guard.dart index 01da8adf..5f792dbc 100644 --- a/ui/flutter/lib/app/router/mobile_exit_guard.dart +++ b/ui/flutter/lib/app/router/mobile_exit_guard.dart @@ -5,7 +5,8 @@ import 'package:flutter/widgets.dart'; import '../../shared/navigation/app_exit_confirmation_controller.dart'; -/// Confirms app exit only on the home route; child routes pop normally. +/// Handles exit at the shell route after the nested Navigator declines a pop. +/// Child routes and dialogs still pop through GoRouter before reaching this guard. class MobileExitGuard extends StatefulWidget { const MobileExitGuard({super.key, required this.child}); @@ -45,7 +46,16 @@ class _MobileExitGuardState extends State with WidgetsBindingOb onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_controller.handleBack(context)); }, - child: widget.child, + child: NotificationListener( + onNotification: (notification) { + if (notification.canHandlePop) return false; + // An empty nested back stack still has this shell's exit guard. + // Otherwise its startup notification disables Android's back callback. + const NavigationNotification(canHandlePop: true).dispatch(context); + return true; + }, + child: widget.child, + ), ); } } diff --git a/ui/flutter/lib/app/router/runtime_page.dart b/ui/flutter/lib/app/router/runtime_page.dart new file mode 100644 index 00000000..e63fdfed --- /dev/null +++ b/ui/flutter/lib/app/router/runtime_page.dart @@ -0,0 +1,30 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../application/app_runtime_controller.dart'; +import '../../l10n/l10n.dart'; + +/// Gates page content without unmounting the Navigator during startup. +class RuntimePage extends ConsumerWidget { + const RuntimePage({super.key, required this.builder}); + + final WidgetBuilder builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref + .watch(appRuntimeControllerProvider) + .when( + loading: () => const Scaffold(child: Center(child: CircularProgressIndicator())), + error: (error, _) => Scaffold( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), + ), + ), + ), + data: (_) => builder(context), + ); + } +} diff --git a/ui/flutter/lib/app/router/shells/main_shell.dart b/ui/flutter/lib/app/router/shells/main_shell.dart index dbcd5b54..4af3f1de 100644 --- a/ui/flutter/lib/app/router/shells/main_shell.dart +++ b/ui/flutter/lib/app/router/shells/main_shell.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../api/model/create_task.dart'; import '../../application/app_deep_link_controller.dart'; @@ -16,7 +15,6 @@ import '../../../features/tasks/application/pending_create_task.dart'; import '../../../features/tasks/application/pending_update_task.dart'; import '../../../features/tasks/application/tasks_controller.dart'; import '../../../features/tasks/presentation/widgets/pending_update_dialog.dart'; -import '../../../l10n/l10n.dart'; import '../../../core/window/app_window_launcher.dart'; import '../../../shared/widgets/app_toast.dart'; @@ -62,24 +60,13 @@ class _MainShellState extends ConsumerState { } }); final runtime = ref.watch(appRuntimeControllerProvider); - final content = runtime.when( - loading: () => const shad.Scaffold(child: Center(child: shad.CircularProgressIndicator())), - error: (error, _) => shad.Scaffold( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), - ), - ), - ), - data: (_) { - ref.watch(appPlatformControllerProvider); - ref.watch(appDeepLinkControllerProvider); - ref.watch(appNotificationControllerProvider); - return widget.child; - }, - ); - return content; + if (runtime.hasValue) { + ref.watch(appPlatformControllerProvider); + ref.watch(appDeepLinkControllerProvider); + ref.watch(appNotificationControllerProvider); + } + // Keep the nested Navigator mounted even while the runtime is starting. + return widget.child; } void _schedulePendingUpdateRequest(PendingUpdateRequest request) { diff --git a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart index cd3a3b55..87357fa0 100644 --- a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart +++ b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart @@ -23,10 +23,13 @@ class AppExitConfirmationController { final themes = InheritedTheme.capture(from: context, to: Overlay.of(context, rootOverlay: true).context); _toast = OverlayEntry( builder: (_) => IgnorePointer( - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: themes.wrap(AppToastContent(message: message)), + child: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(24), + child: themes.wrap(AppToastContent(message: message)), + ), ), ), ), diff --git a/ui/flutter/test/app/router/app_startup_back_test.dart b/ui/flutter/test/app/router/app_startup_back_test.dart new file mode 100644 index 00000000..6cf89992 --- /dev/null +++ b/ui/flutter/test/app/router/app_startup_back_test.dart @@ -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(); + + @override + Future build() => pending.future; +} + +void main() { + testWidgets( + 'cold startup registers Android back and confirms exit before runtime is ready', + (tester) async { + final platformCalls = []; + 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), + ); +} diff --git a/ui/flutter/test/app/router/mobile_exit_guard_test.dart b/ui/flutter/test/app/router/mobile_exit_guard_test.dart index 58cb1985..1d70561a 100644 --- a/ui/flutter/test/app/router/mobile_exit_guard_test.dart +++ b/ui/flutter/test/app/router/mobile_exit_guard_test.dart @@ -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( + 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)); } diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index b90329cc..ed2216d1 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:window_manager/window_manager.dart'; +import 'package:gopeed/features/home/presentation/pages/home_page.dart'; import 'package:gopeed/app/app.dart'; import 'package:gopeed/app/router/app_router.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; @@ -2254,15 +2255,16 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('settings mobile back returns one level before requiring a second back to exit', ( - WidgetTester tester, - ) async { + testWidgets('settings mobile back returns through home before confirming exit', (WidgetTester tester) async { await _setTestSize(tester, const Size(390, 760)); debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); var systemPopCalls = 0; + bool? frameworkHandlesBack; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'SystemNavigator.pop') systemPopCalls++; + if (call.method == 'SystemNavigator.setFrameworkHandlesBack') frameworkHandlesBack = call.arguments as bool; return null; }); addTearDown(() { @@ -2281,6 +2283,7 @@ void main() { ); await tester.pumpAndSettle(); + expect(frameworkHandlesBack, isTrue); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); await tester.tap(find.text('Basic')); @@ -2294,6 +2297,13 @@ void main() { expect(find.byKey(const ValueKey('theme-mode-system')), findsNothing); expect(systemPopCalls, 0); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsOneWidget); + expect(find.text('Press back again to exit'), findsNothing); + expect(systemPopCalls, 0); + expect(frameworkHandlesBack, isTrue); + await tester.binding.handlePopRoute(); await tester.pump(); expect(find.text('Press back again to exit'), findsOneWidget);