fix(ui): preserve mobile home route and confirm Android exit

This commit is contained in:
Levi committed 2026-09-12 18:56:52 +08:00
1 parent 8f9bac9e6b
commit ee6b073bc1
7 files changed
+307 -66

No files matched your search

+33 -24
View File
@@ -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<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -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,
],
),
],
@@ -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<MobileExitGuard> createState() => _MobileExitGuardState();
}
class _MobileExitGuardState extends State<MobileExitGuard> 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,
);
}
}
@@ -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<MainShell> {
String? _shownUpdateVersion;
bool _handlingPendingUpdateRequest = false;
final _exitConfirmationController = AppExitConfirmationController();
@override
Widget build(BuildContext context) {
@@ -82,23 +79,7 @@ class _MainShellState extends ConsumerState<MainShell> {
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) {
@@ -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<void> 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;
}
}
+28 -15
View File
@@ -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)),
);
}
}
@@ -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);
}
});
}
}
@@ -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<MethodCall> 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<void> 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));
}
}