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

+26 -15
View File
@@ -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<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -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);
@@ -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<MobileExitGuard> with WidgetsBindingOb
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_controller.handleBack(context));
},
child: widget.child,
child: NotificationListener<NavigationNotification>(
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,
),
);
}
}
@@ -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),
);
}
}
@@ -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<MainShell> {
}
});
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) {
@@ -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)),
),
),
),
),
@@ -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));
}
+13 -3
View File
@@ -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);