mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 17:10:29 +00:00
fix(ui): preserve mobile home route and confirm Android exit (#1507)
This commit is contained in:
10 files changed
+484
-96
No files matched your search
@@ -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<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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: 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,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<MainShell> {
|
||||
String? _shownUpdateVersion;
|
||||
bool _handlingPendingUpdateRequest = false;
|
||||
final _exitConfirmationController = AppExitConfirmationController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -65,40 +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 _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) {
|
||||
|
||||
@@ -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<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: 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;
|
||||
}
|
||||
}
|
||||
@@ -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,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),
|
||||
);
|
||||
}
|
||||
@@ -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<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) => 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<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 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<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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user