diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 05056135..c1d7ee62 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,6 +16,8 @@ import '../../features/tasks/domain/task_record.dart'; 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(); @@ -27,6 +30,36 @@ class AppRouter { } static GoRouter build(WebAuthController webAuthController) { + // Keep desktop and web sections as peers; native mobile retains home below them. + final isMobile = + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); + final sectionRoutes = [ + GoRoute( + path: isMobile ? 'extensions' : '/extensions', + builder: _runtimePage((context, state) => const ExtensionsPage()), + routes: [ + GoRoute( + path: ':id', + 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: _runtimePage((context, state) => const SettingsPage()), + routes: [ + GoRoute( + path: ':section', + builder: _runtimePage((context, state) => SettingsPage(sectionKey: state.pathParameters['section'])), + ), + ], + ), + ]; return GoRouter( navigatorKey: rootNavigatorKey, refreshListenable: webAuthController, @@ -44,60 +77,47 @@ 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 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, + ), ), ), ], ), + if (isMobile) ...sectionRoutes, ], ), - GoRoute( - path: '/extensions', - builder: (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, - ), - ), - ], - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - routes: [ - GoRoute( - path: ':section', - builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), - ), - ], - ), + if (!isMobile) ...sectionRoutes, ], ), ], ); } + 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 new file mode 100644 index 00000000..5f792dbc --- /dev/null +++ b/ui/flutter/lib/app/router/mobile_exit_guard.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../shared/navigation/app_exit_confirmation_controller.dart'; + +/// 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}); + + final Widget child; + + @override + State createState() => _MobileExitGuardState(); +} + +class _MobileExitGuardState extends State with WidgetsBindingObserver { + final _controller = AppExitConfirmationController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) _controller.dispose(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return widget.child; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_controller.handleBack(context)); + }, + 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 7b5e560c..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,10 +15,7 @@ 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 '../../../core/utils/breakpoints.dart'; -import '../../../shared/navigation/app_exit_confirmation_controller.dart'; import '../../../shared/widgets/app_toast.dart'; class MainShell extends ConsumerStatefulWidget { @@ -34,7 +30,6 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { String? _shownUpdateVersion; bool _handlingPendingUpdateRequest = false; - final _exitConfirmationController = AppExitConfirmationController(); @override Widget build(BuildContext context) { @@ -65,40 +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 _buildMobileBackGuard(context, content); - } - - Widget _buildMobileBackGuard(BuildContext context, Widget child) { - final isAndroidMobile = - !kIsWeb && - defaultTargetPlatform == TargetPlatform.android && - MediaQuery.sizeOf(context).width < Breakpoints.mobile; - if (!isAndroidMobile || context.canPop()) return child; - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) unawaited(_exitConfirmationController.handleBack(context)); - }, - child: child, - ); + 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 4673ec49..87357fa0 100644 --- a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart +++ b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -7,18 +9,40 @@ import '../widgets/app_toast.dart'; class AppExitConfirmationController { static const confirmationWindow = Duration(seconds: 2); - DateTime? _lastBackAt; + OverlayEntry? _toast; + Timer? _timer; Future handleBack(BuildContext context) async { - final now = DateTime.now(); - final lastBackAt = _lastBackAt; - if (lastBackAt != null && now.difference(lastBackAt) <= confirmationWindow) { - _lastBackAt = null; + if (_toast != null) { + dispose(); await SystemNavigator.pop(); return; } - _lastBackAt = now; - showAppToast(context, context.l10n.pressBackAgainToExit); + final message = context.l10n.pressBackAgainToExit; + final themes = InheritedTheme.capture(from: context, to: Overlay.of(context, rootOverlay: true).context); + _toast = OverlayEntry( + builder: (_) => IgnorePointer( + child: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(24), + child: themes.wrap(AppToastContent(message: message)), + ), + ), + ), + ), + ); + Overlay.of(context, rootOverlay: true).insert(_toast!); + _timer = Timer(confirmationWindow, dispose); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + _toast?.remove(); + _toast?.dispose(); + _toast = null; } } diff --git a/ui/flutter/lib/shared/widgets/app_toast.dart b/ui/flutter/lib/shared/widgets/app_toast.dart index eb896516..265bd2f0 100644 --- a/ui/flutter/lib/shared/widgets/app_toast.dart +++ b/ui/flutter/lib/shared/widgets/app_toast.dart @@ -11,11 +11,6 @@ enum AppToastType { info, success, error } /// short messages stay compact while long messages wrap before becoming wider /// than the configured maximum. Errors and normal notices use the same layout. void showAppToast(BuildContext context, String message, {AppToastType type = AppToastType.info}) { - final palette = AppPalette.of(context); - final borderColor = switch (type) { - AppToastType.error => palette.error.withValues(alpha: 0.55), - AppToastType.success || AppToastType.info => palette.border, - }; shad.showToast( context: context, location: shad.ToastLocation.topCenter, @@ -23,16 +18,34 @@ void showAppToast(BuildContext context, String message, {AppToastType type = App alignment: Alignment.topCenter, widthFactor: 1, heightFactor: 1, - child: Container( - key: const ValueKey('app-toast-content'), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: palette.cardBg, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: borderColor), - ), - child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), - ), + child: AppToastContent(message: message, type: type), ), ); } + +/// Shared toast surface for edge notifications and centered exit prompts. +class AppToastContent extends StatelessWidget { + const AppToastContent({super.key, required this.message, this.type = AppToastType.info}); + + final String message; + final AppToastType type; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final borderColor = switch (type) { + AppToastType.error => palette.error.withValues(alpha: 0.55), + AppToastType.success || AppToastType.info => palette.border, + }; + return Container( + key: const ValueKey('app-toast-content'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), + ); + } +} diff --git a/ui/flutter/test/app/router/app_router_test.dart b/ui/flutter/test/app/router/app_router_test.dart new file mode 100644 index 00000000..bc0bea1c --- /dev/null +++ b/ui/flutter/test/app/router/app_router_test.dart @@ -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); + } + }); + } +} 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 new file mode 100644 index 00000000..1d70561a --- /dev/null +++ b/ui/flutter/test/app/router/mobile_exit_guard_test.dart @@ -0,0 +1,161 @@ +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 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) => MobileExitGuard(child: child), + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const 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 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 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)).dx, 400); + expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, 576); + 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)); + } + 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 7d95ab0e..4db9414e 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'; @@ -2269,15 +2270,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(() { @@ -2296,6 +2298,7 @@ void main() { ); await tester.pumpAndSettle(); + expect(frameworkHandlesBack, isTrue); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); await tester.tap(find.text('Basic')); @@ -2309,6 +2312,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);