From ee6b073bc13562bc98f93f7fe3673949182619d2 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 18:56:52 +0800 Subject: [PATCH] fix(ui): preserve mobile home route and confirm Android exit --- ui/flutter/lib/app/router/app_router.dart | 57 ++++---- .../lib/app/router/mobile_exit_guard.dart | 51 +++++++ .../lib/app/router/shells/main_shell.dart | 21 +-- .../app_exit_confirmation_controller.dart | 35 ++++- ui/flutter/lib/shared/widgets/app_toast.dart | 43 ++++-- .../test/app/router/app_router_test.dart | 36 +++++ .../app/router/mobile_exit_guard_test.dart | 130 ++++++++++++++++++ 7 files changed, 307 insertions(+), 66 deletions(-) create mode 100644 ui/flutter/lib/app/router/mobile_exit_guard.dart create mode 100644 ui/flutter/test/app/router/app_router_test.dart create mode 100644 ui/flutter/test/app/router/mobile_exit_guard_test.dart diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 05056135..78007c3c 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,7 @@ 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'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); @@ -27,6 +29,34 @@ 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: (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: isMobile ? 'settings' : '/settings', + builder: (context, state) => const SettingsPage(), + routes: [ + GoRoute( + path: ':section', + builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), + ), + ], + ), + ]; return GoRouter( navigatorKey: rootNavigatorKey, refreshListenable: webAuthController, @@ -48,7 +78,7 @@ class AppRouter { routes: [ GoRoute( path: '/', - builder: (context, state) => const HomePage(), + builder: (context, state) => const MobileExitGuard(child: HomePage()), routes: [ GoRoute(path: 'create', builder: (context, state) => const CreateTaskWindowPage()), GoRoute( @@ -67,31 +97,10 @@ class AppRouter { ), ], ), + 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, ], ), ], 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..01da8adf --- /dev/null +++ b/ui/flutter/lib/app/router/mobile_exit_guard.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +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. +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: widget.child, + ); + } +} diff --git a/ui/flutter/lib/app/router/shells/main_shell.dart b/ui/flutter/lib/app/router/shells/main_shell.dart index 7b5e560c..dbcd5b54 100644 --- a/ui/flutter/lib/app/router/shells/main_shell.dart +++ b/ui/flutter/lib/app/router/shells/main_shell.dart @@ -18,8 +18,6 @@ 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 +32,6 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { String? _shownUpdateVersion; bool _handlingPendingUpdateRequest = false; - final _exitConfirmationController = AppExitConfirmationController(); @override Widget build(BuildContext context) { @@ -82,23 +79,7 @@ class _MainShellState extends ConsumerState { 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, - ); + return content; } 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..cd3a3b55 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,37 @@ 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: Center( + 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/mobile_exit_guard_test.dart b/ui/flutter/test/app/router/mobile_exit_guard_test.dart new file mode 100644 index 00000000..58cb1985 --- /dev/null +++ b/ui/flutter/test/app/router/mobile_exit_guard_test.dart @@ -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 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 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)); + } +}