From b9914826178f773822ac0afdce7020a03d43f9d7 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 00:19:49 +0800 Subject: [PATCH 1/6] fix(macos): prevent closed task windows from reopening (#1492) --- ui/flutter/lib/app/router/app_router.dart | 9 + .../core/capabilities/app_capabilities.dart | 12 +- .../app_navigation_capability.dart | 15 + .../home/presentation/pages/home_page.dart | 6 + .../application/task_list_navigation.dart | 11 + .../pages/create_task_window_page.dart | 17 +- ui/flutter/macos/Runner/AppDelegate.swift | 18 +- .../create_task_window_page_test.dart | 1164 +++++++++++------ ui/flutter/test/widget_test.dart | 35 + 9 files changed, 847 insertions(+), 440 deletions(-) create mode 100644 ui/flutter/lib/core/capabilities/app_navigation_capability.dart create mode 100644 ui/flutter/lib/features/tasks/application/task_list_navigation.dart diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 29a1d9a9..05056135 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/tasks/application/task_list_navigation.dart'; import '../../features/extensions/presentation/pages/extensions_page.dart'; import '../../features/extensions/presentation/pages/extension_details_page.dart'; @@ -17,6 +19,13 @@ import 'shells/main_shell.dart'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); + static void showDownloadingTasks() { + final context = rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + ProviderScope.containerOf(context, listen: false).read(taskListNavigationProvider.notifier).showDownloading(); + GoRouter.of(context).go('/'); + } + static GoRouter build(WebAuthController webAuthController) { return GoRouter( navigatorKey: rootNavigatorKey, diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart index 6333ea5f..106a42c7 100644 --- a/ui/flutter/lib/core/capabilities/app_capabilities.dart +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -16,12 +16,18 @@ import '../window/app_window_appearance.dart'; import 'capability_rpc.dart'; import 'gopeed_capability.dart'; import 'storage_capability.dart'; +import 'app_navigation_capability.dart'; +import '../../app/router/app_router.dart'; class AppCapabilities { - AppCapabilities(CapabilityInvoker invoker) : gopeed = GopeedService(invoker), storage = AppStorageService(invoker); + AppCapabilities(CapabilityInvoker invoker) + : gopeed = GopeedService(invoker), + storage = AppStorageService(invoker), + navigation = AppNavigationService(invoker); final GopeedService gopeed; final AppStorageService storage; + final AppNavigationService navigation; } class LocalAppCapabilities { @@ -29,6 +35,10 @@ class LocalAppCapabilities { registry = CapabilityRegistry(codecs); _bindGopeed(registry); _bindStorage(registry); + registry.bind(NavigationMethods.showDownloadingTasks, (_) { + AppRouter.showDownloadingTasks(); + return const RpcUnit(); + }); capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); } diff --git a/ui/flutter/lib/core/capabilities/app_navigation_capability.dart b/ui/flutter/lib/core/capabilities/app_navigation_capability.dart new file mode 100644 index 00000000..f1b18ccd --- /dev/null +++ b/ui/flutter/lib/core/capabilities/app_navigation_capability.dart @@ -0,0 +1,15 @@ +import 'capability_rpc.dart'; + +abstract final class NavigationMethods { + static const showDownloadingTasks = RpcMethod('navigation.tasks.downloading'); +} + +class AppNavigationService { + const AppNavigationService(this._invoker); + + final CapabilityInvoker _invoker; + + Future showDownloadingTasks() async { + await _invoker.invoke(NavigationMethods.showDownloadingTasks, const RpcUnit()); + } +} diff --git a/ui/flutter/lib/features/home/presentation/pages/home_page.dart b/ui/flutter/lib/features/home/presentation/pages/home_page.dart index 5d356b3b..f23af3ee 100644 --- a/ui/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -19,6 +19,7 @@ import '../../../../shared/widgets/responsive_navigation_layout.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../../tasks/application/tasks_controller.dart'; +import '../../../tasks/application/task_list_navigation.dart'; import '../../../tasks/application/pending_update_task.dart'; import '../../../tasks/application/task_batch_selection_controller.dart'; import '../../../tasks/domain/task_record.dart'; @@ -86,6 +87,11 @@ class _HomePageState extends ConsumerState { Widget build(BuildContext context) { final palette = AppPalette.of(context); final tasksAsync = ref.watch(tasksControllerProvider); + ref.listen(taskListNavigationProvider, (_, _) { + _searchController.clear(); + _setActiveFilter(_TaskFilter.downloading); + setState(() => _batchMode = false); + }); final tasksState = tasksAsync.value; final tasks = tasksState?.tasks ?? const []; final filteredTasks = _filteredTasks(tasks); diff --git a/ui/flutter/lib/features/tasks/application/task_list_navigation.dart b/ui/flutter/lib/features/tasks/application/task_list_navigation.dart new file mode 100644 index 00000000..1bbbf0fb --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/task_list_navigation.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final taskListNavigationProvider = NotifierProvider(TaskListNavigation.new); + +// A request also resets an already-visible list's filter, search and selection. +class TaskListNavigation extends Notifier { + @override + int build() => 0; + + void showDownloading() => state++; +} diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 4ebc0680..89539624 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -737,6 +737,19 @@ class _CreateTaskWindowPageState extends ConsumerState { await windowManager.close(); } + Future _finishCreation() async { + if (widget.windowController != null) { + // Navigation is best-effort after a successful submission. A navigation + // failure must not leave a completed form available for resubmission. + try { + await ref.read(appCapabilitiesProvider).navigation.showDownloadingTasks(); + } catch (error) { + debugPrint('Unable to select downloading tasks: $error'); + } + } + await _closeWindow(); + } + Future _loadDefaults() async { try { final config = await ref.read(gopeedServiceProvider).getConfig(); @@ -863,7 +876,7 @@ class _CreateTaskWindowPageState extends ConsumerState { ); }), ); - await _closeWindow(); + await _finishCreation(); return; } @@ -879,7 +892,7 @@ class _CreateTaskWindowPageState extends ConsumerState { } return; } - await _closeWindow(); + await _finishCreation(); } catch (error) { if (mounted) { _showToast(error.toString()); diff --git a/ui/flutter/macos/Runner/AppDelegate.swift b/ui/flutter/macos/Runner/AppDelegate.swift index 91c1ed49..5e0636a3 100644 --- a/ui/flutter/macos/Runner/AppDelegate.swift +++ b/ui/flutter/macos/Runner/AppDelegate.swift @@ -26,16 +26,18 @@ class AppDelegate: FlutterAppDelegate { } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if !flag { - for window in NSApp.windows { - if !window.isVisible { - window.setIsVisible(true) - } - window.makeKeyAndOrderFront(self) + // Closed child windows can remain in NSApp.windows. Restoring every + // window resurrects completed create-task forms with their last frame. + // Use the main window even when an unfinished child is still visible. + if let window = sender.windows.first(where: { $0 is MainFlutterWindow }) { + if window.isMiniaturized { + window.deminiaturize(self) } - NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(self) + sender.activate(ignoringOtherApps: true) } - return true + // We handled reopening; AppKit must not restore other retained windows. + return false } override func application(_ sender: NSApplication, openFile filename: String) -> Bool { diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index 96da7409..5ae33c45 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -1,6 +1,9 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/widgets.dart' as flutter show Row; -import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart' show Icons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,6 +13,7 @@ import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/request.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/core/capabilities/capability_rpc.dart'; import 'package:gopeed/core/capabilities/gopeed_capability.dart'; import 'package:gopeed/core/capabilities/storage_capability.dart'; @@ -23,91 +27,184 @@ import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { - testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - tester.view.physicalSize = const Size(700, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - CreateTask? submitted; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-id'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final container = ProviderContainer( - overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], - ); - addTearDown(container.dispose); - final pending = container.read(pendingCreateTaskProvider.notifier); - pending.set( - CreateTask( - req: Request( - url: 'https://example.com/first.zip', - rawUrl: 'https://example.com/first', - labels: {'source': 'first'}, - extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(), - proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'), - skipVerifyCert: true, + for (final navigationFails in [false, true]) { + testWidgets( + 'child creation selects downloads before closing (navigation fails: $navigationFails)', + (tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final events = []; + const channel = MethodChannel('window_manager'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + events.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final config = DownloaderConfig(downloadDir: '/downloads') + ..extra.defaultDirectDownload = true; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind(GopeedMethods.createTask, (_) { + events.add('created'); + return 'task-id'; + }) + ..bind(NavigationMethods.showDownloadingTasks, (_) { + events.add('downloads'); + if (navigationFails) throw StateError('Navigation unavailable'); + return const RpcUnit(); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appCapabilitiesProvider.overrideWithValue( + AppCapabilities(LocalCapabilityInvoker(registry)), + ), + ], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: CreateTaskWindowPage( + windowController: WindowController.fromWindowId('child'), + initialTask: CreateTask( + req: Request(url: 'https://example.com/file.apk'), + ), + ), + ), + ), ), - ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - routerConfig: router, - builder: (_, child) => AppComponentThemes(child: child!), - ), - ), - ); - await tester.pumpAndSettle(); - expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip'); - expect(container.read(pendingCreateTaskProvider), isNull); - final pageState = tester.state(find.byType(CreateTaskWindowPage)); - - for (final url in [ - 'https://example.com/second.zip', - 'https://example.com/third.zip', - 'https://example.com/third.zip', - ]) { - pending.set(CreateTask(req: Request(url: url))); - router.go('/create'); + ); await tester.pumpAndSettle(); - expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState)); - expect(_fieldText(tester, 'create-task-url-input'), url); - expect(container.read(pendingCreateTaskProvider), isNull); - } - await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle'))); - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - expect(submitted?.req?.url, 'https://example.com/third.zip'); - expect(submitted?.req?.rawUrl, isNull); - expect(submitted?.req?.labels, isNull); - expect(submitted?.req?.extra, isNull); - expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); - expect(submitted?.req?.skipVerifyCert, isFalse); - expect(tester.takeException(), isNull); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }); + await tester.tap(find.text('Confirm')); + // The mocked native close deliberately leaves the widget mounted. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(events, ['created', 'downloads', 'close']); + await tester.pumpWidget(const SizedBox()); + }, + ); + } - testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async { + testWidgets( + 'repeated external tasks update the open create route and replace request metadata', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + CreateTask? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads'), + ) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue( + AppCapabilities(LocalCapabilityInvoker(registry)), + ), + ], + ); + addTearDown(container.dispose); + final pending = container.read(pendingCreateTaskProvider.notifier); + pending.set( + CreateTask( + req: Request( + url: 'https://example.com/first.zip', + rawUrl: 'https://example.com/first', + labels: {'source': 'first'}, + extra: ReqExtraHttp( + method: 'POST', + body: 'old-body', + header: {'Cookie': 'old-cookie'}, + ).toJson(), + proxy: RequestProxy( + mode: RequestProxyMode.custom, + host: 'localhost:8080', + ), + skipVerifyCert: true, + ), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/create', + builder: (_, _) => const CreateTaskWindowPage(), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + _fieldText(tester, 'create-task-url-input'), + 'https://example.com/first.zip', + ); + expect(container.read(pendingCreateTaskProvider), isNull); + final pageState = tester.state(find.byType(CreateTaskWindowPage)); + + for (final url in [ + 'https://example.com/second.zip', + 'https://example.com/third.zip', + 'https://example.com/third.zip', + ]) { + pending.set(CreateTask(req: Request(url: url))); + router.go('/create'); + await tester.pumpAndSettle(); + expect( + tester.state(find.byType(CreateTaskWindowPage)), + same(pageState), + ); + expect(_fieldText(tester, 'create-task-url-input'), url); + expect(container.read(pendingCreateTaskProvider), isNull); + } + await tester.tap( + find.byKey(const ValueKey('create-task-direct-download-toggle')), + ); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted?.req?.url, 'https://example.com/third.zip'); + expect(submitted?.req?.rawUrl, isNull); + expect(submitted?.req?.labels, isNull); + expect(submitted?.req?.extra, isNull); + expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); + expect(submitted?.req?.skipVerifyCert, isFalse); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets('mobile create task page starts below the system status bar', ( + WidgetTester tester, + ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); tester.view.devicePixelRatio = 1; @@ -117,7 +214,10 @@ void main() { addTearDown(tester.view.resetPadding); final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads'), + ); final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); try { @@ -133,367 +233,573 @@ void main() { ); await tester.pumpAndSettle(); - final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); + final contentPadding = tester.widget( + find.byKey(const ValueKey('create-task-safe-content')), + ); expect((contentPadding.padding as EdgeInsets).top, 32); } finally { debugDefaultTargetPlatformOverride = null; } }); - testWidgets('browser extension request parameters populate the form and survive submission', ( - WidgetTester tester, - ) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; - final androidLocations = {'application': 'D:/Downloads', 'downloads': 'G:/Manually selected'}; - DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => androidLocations; - DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; - addTearDown(() { - DownloadDirectoryPicker.debugPlatformOverride = null; - DownloadDirectoryPicker.debugAndroidLocationsLoader = null; - DownloadDirectoryPicker.debugDownloadsPreparer = null; - }); - - CreateTask? submitted; - final config = DownloaderConfig(downloadDir: 'C:/Downloads') - ..extra.defaultDirectDownload = true - ..extra.downloadCategories = [ - DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), - DownloadCategory(name: 'Archives', path: 'F:/Archives'), - DownloadCategory(name: 'Long video category', path: 'G:/Videos'), - DownloadCategory(name: 'Long document category', path: 'H:/Documents'), - ] - ..protocolConfig.http.connections = 8; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => config) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-task'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - - final initialTask = CreateTask( - req: Request( - rawUrl: 'https://page.example/download', - url: 'https://cdn.example/archive.zip', - extra: ReqExtraHttp( - method: 'POST', - header: const { - 'User-Agent': 'Browser UA', - 'Cookie': 'session=abc', - 'Referer': 'https://page.example/', - 'Authorization': 'Bearer token', - }, - body: 'request-body', - ).toJson(), - labels: const {'source': 'browser-extension'}, - proxy: RequestProxy( - mode: RequestProxyMode.custom, - scheme: 'socks5', - host: '127.0.0.1:7890', - usr: 'proxy-user', - pwd: 'proxy-password', - ), - skipVerifyCert: true, - ), - opts: Options( - name: 'archive.zip', - path: 'D:/Downloads', - extra: OptsExtraHttp( - connections: 12, - autoTorrent: true, - deleteTorrentAfterDownload: true, - autoExtract: true, - archivePassword: 'archive-password', - deleteAfterExtract: true, - ).toJson(), - ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute( - path: '/create', - builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), - ), - ], - ); - addTearDown(router.dispose); - - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), - routerConfig: router, - ), - ), - ); - await tester.pumpAndSettle(); - - final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); - expect(connectionsInput, findsOneWidget); - expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth); - final connectionsField = tester.widget(connectionsInput); - expect(connectionsField.controller!.text, '12'); - expect(connectionsField.features.single, isA()); - - final advancedSection = find.byKey(const ValueKey('create-task-advanced-section')); - expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showSecond); - await tester.tap(find.text('Advanced')); - await tester.pumpAndSettle(); - expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showFirst); - - expect(_fieldText(tester, 'create-task-http-header-name-0'), 'User-Agent'); - expect(_fieldText(tester, 'create-task-http-header-value-0'), 'Browser UA'); - expect(_fieldText(tester, 'create-task-http-header-name-3'), 'Authorization'); - expect(_fieldText(tester, 'create-task-http-header-value-3'), 'Bearer token'); - expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); - expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); - expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); - expect(_fieldText(tester, 'create-task-proxy-password'), 'proxy-password'); - expect(_fieldText(tester, 'create-task-archive-password'), 'archive-password'); - expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-auto-torrent')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-delete-torrent')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-delete-after-extract')), findsOneWidget); - - expect(find.text('Archives'), findsNWidgets(2)); - expect(find.text('Quick folders'), findsNothing); - expect(find.byKey(const ValueKey('create-task-category-0')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-category-1')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-category-2')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-category-3')), findsOneWidget); - final shortcutRow = find.byKey(const ValueKey('create-task-directory-shortcuts')); - expect(tester.getSize(shortcutRow).height, 28); - final rememberDirectory = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); - final directDownloadCheckbox = find.byKey(const ValueKey('create-task-direct-download-checkbox')); - final rememberDirectoryRow = find.byKey(const ValueKey('create-task-remember-download-directory')); - final rememberDirectoryLabel = find.byKey(const ValueKey('create-task-remember-download-directory-label')); - final directDownloadLabel = find.descendant( - of: find.byKey(const ValueKey('create-task-direct-download-toggle')), - matching: find.text('Skip resolving and create tasks immediately'), - ); - final directoryComponent = find.byKey(const ValueKey('create-task-directory-component')); - final directoryOptionsRow = find.byKey(const ValueKey('create-task-directory-options-row')); - final directoryOptionsLayout = find.byKey(const ValueKey('create-task-directory-options-layout')); - final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker')); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - expect(find.text('Save as default download directory'), findsOneWidget); - final rememberStyle = tester.widget(rememberDirectoryLabel).style!; - final directStyle = tester.widget(directDownloadLabel).style!; - expect(rememberStyle.color, directStyle.color); - expect(rememberStyle.fontSize, directStyle.fontSize); - expect(rememberStyle.fontWeight, directStyle.fontWeight); - expect(tester.widget(rememberDirectory).size, isNull); - expect(tester.getSize(rememberDirectory), const Size.square(AppDesignTokens.checkboxSize)); - expect(tester.getCenter(rememberDirectory).dx, closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5)); - final rememberDirectoryText = tester.widget(rememberDirectoryLabel); - final directDownloadText = tester.widget(directDownloadLabel); - expect(rememberDirectoryText.style, directDownloadText.style); - expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); - expect(rememberDirectoryText.overflow, directDownloadText.overflow); - expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); - expect(tester.widget(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween); - expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5)); - expect(tester.getTopRight(directoryOptionsRow).dx, closeTo(tester.getTopRight(directoryComponent).dx, 0.5)); - expect( - tester.getTopRight(find.byKey(const ValueKey('create-task-category-3'))).dx, - closeTo(tester.getTopRight(directoryComponent).dx, 0.5), - ); - - tester.view.physicalSize = const Size(520, 720); - await tester.pumpAndSettle(); - final shortcutContent = find.byKey(const ValueKey('create-task-directory-shortcuts-content')); - expect(tester.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy)); - expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5)); - expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width)); - final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable)); - expect(tester.state(shortcutScrollable).position.maxScrollExtent, greaterThan(0)); - - tester.view.physicalSize = const Size(719, 720); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))), - isA(), - ); - - tester.view.physicalSize = const Size(720, 720); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))).mainAxisAlignment, - MainAxisAlignment.spaceBetween, - ); - - tester.view.physicalSize = const Size(1024, 720); - await tester.pumpAndSettle(); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); - - expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); - await tester.pump(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - expect( - find.descendant( - of: find.byKey(const ValueKey('create-task-category-3')), - matching: find.byIcon(Icons.folder_outlined), - ), - findsOneWidget, - ); - - androidLocations['downloads'] = 'C:/Downloads'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - - androidLocations['downloads'] = 'G:/Manually selected'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); - - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - - final request = submitted?.req; - expect(request, isNotNull); - expect(request?.rawUrl, initialTask.req?.rawUrl); - expect(request?.labels, initialTask.req?.labels); - expect(request?.proxy?.mode, RequestProxyMode.custom); - expect(request?.proxy?.scheme, 'socks5'); - expect(request?.proxy?.host, '127.0.0.1:7890'); - expect(request?.proxy?.usr, 'proxy-user'); - expect(request?.proxy?.pwd, 'proxy-password'); - expect(request?.skipVerifyCert, isTrue); - expect(ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), initialTask.req?.extra); - expect(submitted?.opts?.path, 'G:/Manually selected'); - final options = OptsExtraHttp.fromJson(submitted?.opts?.extra! as Map); - expect(options.connections, 12); - expect(options.autoTorrent, isTrue); - expect(options.deleteTorrentAfterDownload, isTrue); - expect(options.autoExtract, isTrue); - expect(options.archivePassword, 'archive-password'); - expect(options.deleteAfterExtract, isTrue); - expect(submitted?.opts?.asDefaultPath, isTrue); - }); - - testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( - WidgetTester tester, - ) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final histories = [ - 'https://example.com/alpha.zip', - 'https://example.com/beta.iso', - 'magnet:?xt=urn:btih:gamma', - ]; - String? removedHistory; - var cleared = false; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) - ..bind(StorageMethods.getCreateHistory, (_) => List.unmodifiable(histories)) - ..bind(StorageMethods.removeCreateHistory, (value) { - removedHistory = value; - histories.remove(value); - return const RpcUnit(); - }) - ..bind(StorageMethods.clearCreateHistory, (_) { - cleared = true; - histories.clear(); - return const RpcUnit(); + testWidgets( + 'browser extension request parameters populate the form and survive submission', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + final androidLocations = { + 'application': 'D:/Downloads', + 'downloads': 'G:/Manually selected', + }; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => + androidLocations; + DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; + addTearDown(() { + DownloadDirectoryPicker.debugPlatformOverride = null; + DownloadDirectoryPicker.debugAndroidLocationsLoader = null; + DownloadDirectoryPicker.debugDownloadsPreparer = null; }); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: AppComponentThemes(child: const CreateTaskWindowPage()), + CreateTask? submitted; + final config = DownloaderConfig(downloadDir: 'C:/Downloads') + ..extra.defaultDirectDownload = true + ..extra.downloadCategories = [ + DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), + DownloadCategory(name: 'Archives', path: 'F:/Archives'), + DownloadCategory(name: 'Long video category', path: 'G:/Videos'), + DownloadCategory( + name: 'Long document category', + path: 'H:/Documents', + ), + ] + ..protocolConfig.http.connections = 8; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-task'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + final initialTask = CreateTask( + req: Request( + rawUrl: 'https://page.example/download', + url: 'https://cdn.example/archive.zip', + extra: ReqExtraHttp( + method: 'POST', + header: const { + 'User-Agent': 'Browser UA', + 'Cookie': 'session=abc', + 'Referer': 'https://page.example/', + 'Authorization': 'Bearer token', + }, + body: 'request-body', + ).toJson(), + labels: const {'source': 'browser-extension'}, + proxy: RequestProxy( + mode: RequestProxyMode.custom, + scheme: 'socks5', + host: '127.0.0.1:7890', + usr: 'proxy-user', + pwd: 'proxy-password', + ), + skipVerifyCert: true, ), - ), - ); - await tester.pumpAndSettle(); + opts: Options( + name: 'archive.zip', + path: 'D:/Downloads', + extra: OptsExtraHttp( + connections: 12, + autoTorrent: true, + deleteTorrentAfterDownload: true, + autoExtract: true, + archivePassword: 'archive-password', + deleteAfterExtract: true, + ).toJson(), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/create', + builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), + ), + ], + ); + addTearDown(router.dispose); - await tester.tap(find.byIcon(Icons.history)); - await tester.pumpAndSettle(); - expect( - tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, - tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, - ); - final alphaItem = find.byKey(const ValueKey('create-history-item-https://example.com/alpha.zip')); - final betaItem = find.byKey(const ValueKey('create-history-item-https://example.com/beta.iso')); - final gammaItem = find.byKey(const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma')); - expect(alphaItem, findsOneWidget); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); - final clearAction = find.byKey(const ValueKey('create-history-clear')); - final closeAction = find.byKey(const ValueKey('create-history-close')); - expect( - tester.widget(find.ancestor(of: clearAction, matching: find.byType(AppTooltip))).message, - 'Clear History', - ); - expect( - tester.widget(find.ancestor(of: closeAction, matching: find.byType(AppTooltip))).message, - 'Close', - ); - final historyText = tester.widget(find.descendant(of: alphaItem, matching: find.byType(Text))); - expect(historyText.data, contains('\u200B')); - expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + builder: (context, child) => + AppComponentThemes(child: child ?? const SizedBox.shrink()), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); - final filter = find.byKey(const ValueKey('create-history-filter')); - await tester.enterText(filter, 'beta'); - await tester.pump(); - expect(alphaItem, findsNothing); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsNothing); + final connectionsInput = find.byKey( + const ValueKey('create-task-connections-input'), + ); + expect(connectionsInput, findsOneWidget); + expect( + tester.getSize(connectionsInput).width, + AppDesignTokens.settingsNumberControlWidth, + ); + final connectionsField = tester.widget(connectionsInput); + expect(connectionsField.controller!.text, '12'); + expect(connectionsField.features.single, isA()); - await tester.tap(find.byKey(const ValueKey('create-history-delete-https://example.com/beta.iso'))); - await tester.pumpAndSettle(); - expect(removedHistory, 'https://example.com/beta.iso'); - expect(find.text('No History Found'), findsOneWidget); + final advancedSection = find.byKey( + const ValueKey('create-task-advanced-section'), + ); + expect( + tester.widget(advancedSection).crossFadeState, + CrossFadeState.showSecond, + ); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect( + tester.widget(advancedSection).crossFadeState, + CrossFadeState.showFirst, + ); - await tester.enterText(filter, ''); - await tester.pump(); - expect(alphaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); + expect( + _fieldText(tester, 'create-task-http-header-name-0'), + 'User-Agent', + ); + expect( + _fieldText(tester, 'create-task-http-header-value-0'), + 'Browser UA', + ); + expect( + _fieldText(tester, 'create-task-http-header-name-3'), + 'Authorization', + ); + expect( + _fieldText(tester, 'create-task-http-header-value-3'), + 'Bearer token', + ); + expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); + expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); + expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); + expect( + _fieldText(tester, 'create-task-proxy-password'), + 'proxy-password', + ); + expect( + _fieldText(tester, 'create-task-archive-password'), + 'archive-password', + ); + expect( + find.byKey(const ValueKey('create-task-skip-verify-cert')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-auto-torrent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-delete-torrent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-auto-extract')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-delete-after-extract')), + findsOneWidget, + ); - await tester.tap(find.byKey(const ValueKey('create-history-clear'))); - await tester.pumpAndSettle(); - expect(cleared, isTrue); - expect(find.text('No history'), findsOneWidget); - expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); - expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); - }); + expect(find.text('Archives'), findsNWidgets(2)); + expect(find.text('Quick folders'), findsNothing); + expect( + find.byKey(const ValueKey('create-task-category-0')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-category-1')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-category-2')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-category-3')), + findsOneWidget, + ); + final shortcutRow = find.byKey( + const ValueKey('create-task-directory-shortcuts'), + ); + expect(tester.getSize(shortcutRow).height, 28); + final rememberDirectory = find.byKey( + const ValueKey('create-task-remember-download-directory-checkbox'), + ); + final directDownloadCheckbox = find.byKey( + const ValueKey('create-task-direct-download-checkbox'), + ); + final rememberDirectoryRow = find.byKey( + const ValueKey('create-task-remember-download-directory'), + ); + final rememberDirectoryLabel = find.byKey( + const ValueKey('create-task-remember-download-directory-label'), + ); + final directDownloadLabel = find.descendant( + of: find.byKey(const ValueKey('create-task-direct-download-toggle')), + matching: find.text('Skip resolving and create tasks immediately'), + ); + final directoryComponent = find.byKey( + const ValueKey('create-task-directory-component'), + ); + final directoryOptionsRow = find.byKey( + const ValueKey('create-task-directory-options-row'), + ); + final directoryOptionsLayout = find.byKey( + const ValueKey('create-task-directory-options-layout'), + ); + final directoryPicker = find.byKey( + const ValueKey('create-task-directory-picker'), + ); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + expect(find.text('Save as default download directory'), findsOneWidget); + final rememberStyle = tester.widget(rememberDirectoryLabel).style!; + final directStyle = tester.widget(directDownloadLabel).style!; + expect(rememberStyle.color, directStyle.color); + expect(rememberStyle.fontSize, directStyle.fontSize); + expect(rememberStyle.fontWeight, directStyle.fontWeight); + expect(tester.widget(rememberDirectory).size, isNull); + expect( + tester.getSize(rememberDirectory), + const Size.square(AppDesignTokens.checkboxSize), + ); + expect( + tester.getCenter(rememberDirectory).dx, + closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5), + ); + final rememberDirectoryText = tester.widget(rememberDirectoryLabel); + final directDownloadText = tester.widget(directDownloadLabel); + expect(rememberDirectoryText.style, directDownloadText.style); + expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); + expect(rememberDirectoryText.overflow, directDownloadText.overflow); + expect( + tester.widget(directoryPicker).variance, + same(shad.ButtonVariance.outline), + ); + expect( + tester.widget(directoryOptionsLayout).mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + expect( + tester.getCenter(rememberDirectoryRow).dy, + closeTo(tester.getCenter(shortcutRow).dy, 0.5), + ); + expect( + tester.getTopRight(directoryOptionsRow).dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + expect( + tester + .getTopRight(find.byKey(const ValueKey('create-task-category-3'))) + .dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + + tester.view.physicalSize = const Size(520, 720); + await tester.pumpAndSettle(); + final shortcutContent = find.byKey( + const ValueKey('create-task-directory-shortcuts-content'), + ); + expect( + tester.getTopLeft(shortcutRow).dy, + greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy), + ); + expect( + tester.getSize(shortcutRow).width, + closeTo(tester.getSize(directoryComponent).width, 0.5), + ); + expect( + tester.getSize(shortcutContent).width, + greaterThan(tester.getSize(shortcutRow).width), + ); + final shortcutScrollable = find.descendant( + of: shortcutRow, + matching: find.byType(Scrollable), + ); + expect( + tester + .state(shortcutScrollable) + .position + .maxScrollExtent, + greaterThan(0), + ); + + tester.view.physicalSize = const Size(719, 720); + await tester.pumpAndSettle(); + expect( + tester.widget( + find.byKey(const ValueKey('create-task-directory-options-layout')), + ), + isA(), + ); + + tester.view.physicalSize = const Size(720, 720); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey( + const ValueKey('create-task-directory-options-layout'), + ), + ) + .mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + + tester.view.physicalSize = const Size(1024, 720); + await tester.pumpAndSettle(); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-app-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-downloads-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.checked, + ); + + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); + await tester.pump(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('create-task-category-3')), + matching: find.byIcon(Icons.folder_outlined), + ), + findsOneWidget, + ); + + androidLocations['downloads'] = 'C:/Downloads'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-downloads-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + + androidLocations['downloads'] = 'G:/Manually selected'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-downloads-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.checked, + ); + + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + final request = submitted?.req; + expect(request, isNotNull); + expect(request?.rawUrl, initialTask.req?.rawUrl); + expect(request?.labels, initialTask.req?.labels); + expect(request?.proxy?.mode, RequestProxyMode.custom); + expect(request?.proxy?.scheme, 'socks5'); + expect(request?.proxy?.host, '127.0.0.1:7890'); + expect(request?.proxy?.usr, 'proxy-user'); + expect(request?.proxy?.pwd, 'proxy-password'); + expect(request?.skipVerifyCert, isTrue); + expect( + ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), + initialTask.req?.extra, + ); + expect(submitted?.opts?.path, 'G:/Manually selected'); + final options = OptsExtraHttp.fromJson( + submitted?.opts?.extra! as Map, + ); + expect(options.connections, 12); + expect(options.autoTorrent, isTrue); + expect(options.deleteTorrentAfterDownload, isTrue); + expect(options.autoExtract, isTrue); + expect(options.archivePassword, 'archive-password'); + expect(options.deleteAfterExtract, isTrue); + expect(submitted?.opts?.asDefaultPath, isTrue); + }, + ); + + testWidgets( + 'create history supports filtering, deleting one entry, and clearing all entries', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final histories = [ + 'https://example.com/alpha.zip', + 'https://example.com/beta.iso', + 'magnet:?xt=urn:btih:gamma', + ]; + String? removedHistory; + var cleared = false; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads'), + ) + ..bind( + StorageMethods.getCreateHistory, + (_) => List.unmodifiable(histories), + ) + ..bind(StorageMethods.removeCreateHistory, (value) { + removedHistory = value; + histories.remove(value); + return const RpcUnit(); + }) + ..bind(StorageMethods.clearCreateHistory, (_) { + cleared = true; + histories.clear(); + return const RpcUnit(); + }); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.history)); + await tester.pumpAndSettle(); + expect( + tester + .getSize(find.byKey(const ValueKey('create-history-title-bar'))) + .width, + tester + .getSize(find.byKey(const ValueKey('create-history-content'))) + .width, + ); + final alphaItem = find.byKey( + const ValueKey('create-history-item-https://example.com/alpha.zip'), + ); + final betaItem = find.byKey( + const ValueKey('create-history-item-https://example.com/beta.iso'), + ); + final gammaItem = find.byKey( + const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma'), + ); + expect(alphaItem, findsOneWidget); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + final clearAction = find.byKey(const ValueKey('create-history-clear')); + final closeAction = find.byKey(const ValueKey('create-history-close')); + expect( + tester + .widget( + find.ancestor(of: clearAction, matching: find.byType(AppTooltip)), + ) + .message, + 'Clear History', + ); + expect( + tester + .widget( + find.ancestor(of: closeAction, matching: find.byType(AppTooltip)), + ) + .message, + 'Close', + ); + final historyText = tester.widget( + find.descendant(of: alphaItem, matching: find.byType(Text)), + ); + expect(historyText.data, contains('\u200B')); + expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); + + final filter = find.byKey(const ValueKey('create-history-filter')); + await tester.enterText(filter, 'beta'); + await tester.pump(); + expect(alphaItem, findsNothing); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsNothing); + + await tester.tap( + find.byKey( + const ValueKey('create-history-delete-https://example.com/beta.iso'), + ), + ); + await tester.pumpAndSettle(); + expect(removedHistory, 'https://example.com/beta.iso'); + expect(find.text('No History Found'), findsOneWidget); + + await tester.enterText(filter, ''); + await tester.pump(); + expect(alphaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('create-history-clear'))); + await tester.pumpAndSettle(); + expect(cleared, isTrue); + expect(find.text('No history'), findsOneWidget); + expect( + find.byKey(const ValueKey('create-history-clear')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-history-close')), + findsOneWidget, + ); + }, + ); } String _fieldText(WidgetTester tester, String key) { - final editable = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(EditableText)); + final editable = find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(EditableText), + ); return tester.widget(editable).controller.text; } diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index d7fddcbe..fc18a5a5 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -13,6 +13,9 @@ 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/app/app.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/app/application/app_appearance_controller.dart'; import 'package:gopeed/app/application/app_deep_link_controller.dart'; import 'package:gopeed/app/application/app_notification_controller.dart'; @@ -93,6 +96,38 @@ import 'package:gopeed/util/updater.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + testWidgets('child success returns to downloading from completed and another page', (tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(CompletedTasksController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Completed')); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + + // Exercise the same serialized operation dispatched by a child window. + await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {}); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsNothing); + expect(find.text('Create Task'), findsOneWidget); + + GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings'); + await tester.pumpAndSettle(); + await LocalAppCapabilities.instance.capabilities.navigation.showDownloadingTasks(); + await tester.pumpAndSettle(); + expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/'); + expect(find.text('done.zip'), findsNothing); + expect(find.text('Create Task'), findsOneWidget); + }); + test('Web MCP endpoint excludes page path, query, and hash route', () { expect( webMcpEndpoint(Uri.parse('https://gopeed.example:8443/app?source=desktop#/settings/advanced')), From 0cc2b690f8d449ea4b49383fd6989c75c0571720 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:02:53 +0800 Subject: [PATCH 2/6] fix: create task panel mobile UI (#1488) --- .github/workflows/scripts/check_l10n.dart | 21 +- .../presentation/pages/extensions_page.dart | 1 + .../pages/create_task_window_page.dart | 86 +- .../widgets/resolve_file_tree.dart | 4 + .../widgets/resolve_task_actions.dart | 50 + ui/flutter/lib/l10n/app_ar.arb | 1 + ui/flutter/lib/l10n/app_az.arb | 1 + ui/flutter/lib/l10n/app_ca.arb | 1 + ui/flutter/lib/l10n/app_de.arb | 1 + ui/flutter/lib/l10n/app_en.arb | 1 + ui/flutter/lib/l10n/app_es.arb | 1 + ui/flutter/lib/l10n/app_fa.arb | 1 + ui/flutter/lib/l10n/app_fr.arb | 1 + ui/flutter/lib/l10n/app_hu.arb | 1 + ui/flutter/lib/l10n/app_id.arb | 1 + ui/flutter/lib/l10n/app_it.arb | 1 + ui/flutter/lib/l10n/app_ja.arb | 1 + ui/flutter/lib/l10n/app_ko.arb | 1 + ui/flutter/lib/l10n/app_pl.arb | 1 + ui/flutter/lib/l10n/app_pt.arb | 1 + ui/flutter/lib/l10n/app_ru.arb | 1 + ui/flutter/lib/l10n/app_ta.arb | 1 + ui/flutter/lib/l10n/app_tr.arb | 1 + ui/flutter/lib/l10n/app_uk.arb | 1 + ui/flutter/lib/l10n/app_vi.arb | 1 + ui/flutter/lib/l10n/app_zh.arb | 1 + ui/flutter/lib/l10n/app_zh_TW.arb | 1 + .../shared/theme/app_component_themes.dart | 76 +- .../lib/shared/theme/app_design_tokens.dart | 4 + .../shared/widgets/app_loading_button.dart | 10 +- .../lib/shared/widgets/app_number_input.dart | 2 + .../create_task_window_page_test.dart | 1248 +++++++---------- .../resolve_task_actions_test.dart | 77 + .../theme/app_component_themes_test.dart | 24 + .../widgets/app_loading_button_test.dart | 5 +- ui/flutter/test/widget_test.dart | 66 +- 36 files changed, 869 insertions(+), 827 deletions(-) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart create mode 100644 ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart diff --git a/.github/workflows/scripts/check_l10n.dart b/.github/workflows/scripts/check_l10n.dart index a542a51f..712f5419 100644 --- a/.github/workflows/scripts/check_l10n.dart +++ b/.github/workflows/scripts/check_l10n.dart @@ -6,15 +6,19 @@ // What it checks: // - Every ARB file uses the repository's canonical two-space JSON formatting. // - Every app_.arb file declares the matching @@locale value. -// - Message values are not empty. -// - Every locale has exactly the same message keys as app_en.arb. -// - Every translation preserves the placeholder variables used by app_en.arb. +// - Message values are not empty (warning only). +// - Every locale has the same message keys as app_en.arb (warning only). +// - Every translation preserves the placeholder variables used by app_en.arb +// (warning only). // // Notes: // - This script does not judge translation quality. // - It does not generate localization code or fully parse ICU MessageFormat. // `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax // and generate app_localizations*.dart. +// - The script exits with a failure only for malformed JSON, invalid ARB +// formatting, or a mismatched @@locale value. Translation completeness is +// reported as warnings; generation and analysis remain the compile-time gate. import 'dart:convert'; import 'dart:io'; @@ -49,27 +53,24 @@ void main() { keys.where((key) => (messages[key] as String).trim().isEmpty).toList() ..sort(); if (empty.isNotEmpty) { - stderr.writeln('${file.path}: empty messages $empty'); - failed = true; + _warning('${file.path}: empty messages $empty'); } if (file.path == templateFile.path) continue; final missing = templateKeys.difference(keys); final extra = keys.difference(templateKeys); if (missing.isNotEmpty || extra.isNotEmpty) { - stderr.writeln( + _warning( '${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}', ); - failed = true; } for (final key in templateKeys.intersection(keys)) { final expected = _placeholders(template[key] as String); final actual = _placeholders(messages[key] as String); if (!_sameSet(expected, actual)) { - stderr.writeln( + _warning( '${file.path}: $key placeholders $actual do not match $expected', ); - failed = true; } } } @@ -102,3 +103,5 @@ Set _placeholders(String message) => RegExp( /// Compares sets without depending on iteration order. bool _sameSet(Set left, Set right) => left.length == right.length && left.containsAll(right); + +void _warning(String message) => stderr.writeln('warning: $message'); diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index 714acf69..5aa57e6a 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -633,6 +633,7 @@ class _Toolbar extends StatelessWidget { final search = AppTextField( key: const ValueKey('extension-search-input'), controller: searchController, + padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null, placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)), features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))], onSubmitted: onSearch, diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 89539624..05d37d9b 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -29,9 +29,9 @@ import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_http_headers_editor.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_number_input.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; -import '../../../../shared/widgets/app_primary_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -39,6 +39,7 @@ import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; import '../widgets/resolve_file_tree.dart'; +import '../widgets/resolve_task_actions.dart'; class CreateTaskWindowPage extends ConsumerStatefulWidget { const CreateTaskWindowPage({super.key, this.windowController, this.initialTask}); @@ -87,6 +88,7 @@ class _CreateTaskWindowPageState extends ConsumerState { String _configuredDownloadDirectory = ''; String _fileDataUri = ''; bool _programmaticUrlChange = false; + String _lastUrlText = ''; @override void initState() { @@ -172,10 +174,15 @@ class _CreateTaskWindowPageState extends ConsumerState { } void _handleUrlChanged() { - if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { - _fileDataUri = ''; + final urlText = _urlController.text; + if (urlText != _lastUrlText) { + _lastUrlText = urlText; + _renameController.clear(); + if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { + _fileDataUri = ''; + } } - _recognizeMagnetUri(_urlController.text.trim()); + _recognizeMagnetUri(urlText.trim()); } void _applyInitialTask(CreateTask? task) { @@ -220,9 +227,6 @@ class _CreateTaskWindowPageState extends ConsumerState { } } if (opts != null) { - if (opts.name.isNotEmpty) { - _renameController.text = opts.name; - } if (opts.path.isNotEmpty) { _directoryController.text = opts.path; } @@ -370,18 +374,12 @@ class _CreateTaskWindowPageState extends ConsumerState { const SizedBox(height: 16), _FormRow( label: context.l10n.connections, - child: Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: AppDesignTokens.settingsNumberControlWidth, - child: AppNumberInput( - fieldKey: const ValueKey('create-task-connections-input'), - controller: _connectionsController, - min: 1, - max: 256, - hintText: context.l10n.enterCount, - ), - ), + child: AppNumberInput( + fieldKey: const ValueKey('create-task-connections-input'), + controller: _connectionsController, + min: 1, + max: 256, + hintText: context.l10n.enterCount, ), ), const SizedBox(height: 16), @@ -396,10 +394,6 @@ class _CreateTaskWindowPageState extends ConsumerState { pickerKey: const ValueKey('create-task-directory-picker'), controller: _directoryController, hintText: context.l10n.chooseDownloadDirectory, - filled: true, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), onChanged: (_) { if (_asDefaultPath) { setState(() => _asDefaultPath = false); @@ -707,16 +701,12 @@ class _CreateTaskWindowPageState extends ConsumerState { child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))), ), const SizedBox(width: 12), - AppPrimaryButton( - onPressed: _creating ? null : _confirm, - child: SizedBox( - width: 68, - child: Center( - child: _creating - ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) - : Text(context.l10n.confirm), - ), - ), + AppLoadingButton( + key: const ValueKey('create-task-confirm-button'), + onPressed: _confirm, + loading: _creating, + variant: AppLoadingButtonVariant.primary, + child: SizedBox(width: 68, child: Center(child: Text(context.l10n.confirm))), ), ], ), @@ -884,7 +874,6 @@ class _CreateTaskWindowPageState extends ConsumerState { final options = _buildOptions(); final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options)); if (!mounted) return; - _syncResolvedName(result); final created = await _showResolveDialog(request, result); if (!created) { if (mounted) { @@ -1169,19 +1158,11 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), actions: [ - SecondaryButton( - onPressed: submitting ? null : () => closeOverlay(dialogContext, false), - child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))), - ), - AppPrimaryButton( - onPressed: submitting ? null : submit, - child: SizedBox( - width: 68, - child: Center( - child: submitting - ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) - : Text(dialogContext.l10n.create), - ), + flutter.Flexible( + child: ResolveTaskActions( + submitting: submitting, + onCancel: () => closeOverlay(dialogContext, false), + onCreate: submit, ), ), ], @@ -1344,12 +1325,6 @@ class _CreateTaskWindowPageState extends ConsumerState { filterController.dispose(); } - void _syncResolvedName(ResolveResult result) { - if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) { - _renameController.text = result.res.name; - } - } - void _setUrlText(String value, {String fileDataUri = ''}) { _programmaticUrlChange = true; _urlController.text = value; @@ -1691,17 +1666,12 @@ class _WindowTextField extends StatelessWidget { @override Widget build(BuildContext context) { - final palette = AppPalette.of(context); return AppTextField( controller: controller, hintText: hintText, keyboardType: keyboardType, inputFormatters: inputFormatters, obscureText: obscureText, - filled: true, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart index 60e88399..d5f8af8e 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart @@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State { child: FileTreeView( items: _treeItems, keyPrefix: 'resolve-tree', + rowHeight: _resolveTreeRowHeight, + contentTextStyle: _resolveFileNameStyle, headerLeading: ExcludeFocus( child: Checkbox( state: allSelected @@ -336,3 +338,5 @@ extension on _FileTypeFilter { const _videoExts = {'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'}; const _audioExts = {'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'}; const _imageExts = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'}; +const _resolveTreeRowHeight = 36.0; +const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25); diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart new file mode 100644 index 00000000..e9b6dc37 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart @@ -0,0 +1,50 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/widgets.dart' as flutter; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; + +class ResolveTaskActions extends StatelessWidget { + const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate}); + + final bool submitting; + final VoidCallback onCancel; + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + // Size both actions from the wider translated label, including while the + // create button replaces its label with a loader. + return IntrinsicWidth( + child: flutter.Row( + mainAxisSize: MainAxisSize.min, + children: [ + flutter.Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth), + child: SecondaryButton( + key: const ValueKey('resolve-cancel-button'), + onPressed: submitting ? null : onCancel, + child: Text(context.l10n.cancel, maxLines: 1, softWrap: false), + ), + ), + ), + const SizedBox(width: AppDesignTokens.space8), + flutter.Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth), + child: AppLoadingButton( + key: const ValueKey('resolve-create-button'), + onPressed: onCreate, + loading: submitting, + variant: AppLoadingButtonVariant.primary, + child: Text(context.l10n.createAction, maxLines: 1, softWrap: false), + ), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 3569cc64..830d6eb1 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -18,6 +18,7 @@ "exit": "خروج", "pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج", "create": "إنشاء مهمة", + "createAction": "إنشاء", "directDownload": "تنزيل مباشر", "advancedOptions": "خيارات متقدمة", "followSettings": "اتباع الإعدادات", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 70eafe0d..d9d5bcfd 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -18,6 +18,7 @@ "exit": "Çıxış", "pressBackAgainToExit": "Çıxmaq üçün yenidən geri düyməsinə basın", "create": "Tapşırıq yarat", + "createAction": "Yarat", "directDownload": "Birbaşa Endirmə", "advancedOptions": "Əlavə Seçimlər", "followSettings": "Parametrlərə uyğun", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 7743b429..1daea2e6 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -18,6 +18,7 @@ "exit": "Surt", "pressBackAgainToExit": "Torna a prémer enrere per sortir", "create": "Crea una tasca", + "createAction": "Crea", "directDownload": "Baixada directa", "advancedOptions": "Opcions avançades", "followSettings": "Segueix la configuració", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 2a347170..fdd924ff 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -18,6 +18,7 @@ "exit": "Beenden", "pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden", "create": "Aufgabe Erstellen", + "createAction": "Erstellen", "directDownload": "Direkter Download", "advancedOptions": "Erweiterte Einstellungen", "followSettings": "Einstellungen Folgen", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 578e762d..1a0fcfa2 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -18,6 +18,7 @@ "exit": "Exit", "pressBackAgainToExit": "Press back again to exit", "create": "Create Task", + "createAction": "Create", "directDownload": "Direct Download", "advancedOptions": "Advanced Options", "followSettings": "Follow Settings", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 6aef1468..c93209d1 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -18,6 +18,7 @@ "exit": "Salir", "pressBackAgainToExit": "Pulsa Atrás de nuevo para salir", "create": "Crear Tarea", + "createAction": "Crear", "directDownload": "Descarga Directa", "advancedOptions": "Opciones Avanzadas", "followSettings": "Siga las opciones", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 7398b5c3..3943a61d 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -18,6 +18,7 @@ "exit": "خروج", "pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید", "create": "ایجاد کار", + "createAction": "ایجاد", "directDownload": "دانلود مستقیم", "advancedOptions": "گزینه های پیشرفته", "followSettings": "تنظیمات را دنبال کنید", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 43404fdc..4af83656 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -18,6 +18,7 @@ "exit": "Quitter", "pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter", "create": "Créer une tâche", + "createAction": "Créer", "directDownload": "Téléchargement direct", "advancedOptions": "Options avancées", "followSettings": "Suivre les paramètres", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index cd5d2b2d..9c2fd4c8 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -18,6 +18,7 @@ "exit": "Kilépés", "pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez", "create": "Feladat létrehozása", + "createAction": "Létrehozás", "directDownload": "Közvetlen letöltés", "advancedOptions": "Haladó beállítások", "followSettings": "Beállítások követése", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 1f87a970..857ec774 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -18,6 +18,7 @@ "exit": "Keluar", "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", "create": "Membuat Tugas", + "createAction": "Buat", "directDownload": "Unduhan Langsung", "advancedOptions": "Pengaturan Lanjutan", "followSettings": "Mengikuti Pengaturan", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 7d298adb..01263794 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -18,6 +18,7 @@ "exit": "Uscita", "pressBackAgainToExit": "Premi di nuovo Indietro per uscire", "create": "Crea attività", + "createAction": "Crea", "directDownload": "Scaricamento diretto", "advancedOptions": "Opzioni avanzate", "followSettings": "Seguire le impostazioni", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 0bdbc743..d816b478 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -18,6 +18,7 @@ "exit": "終了", "pressBackAgainToExit": "もう一度戻るボタンを押すと終了します", "create": "タスクを作成", + "createAction": "作成", "directDownload": "直接ダウンロード", "advancedOptions": "詳細オプション", "followSettings": "設定に従う", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index db8caf2d..a4d3f9da 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -18,6 +18,7 @@ "exit": "종료", "pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요", "create": "작업 생성", + "createAction": "생성", "directDownload": "바로 다운로드", "advancedOptions": "고급 옵션", "followSettings": "설정값 사용", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index c255d506..6aea60ce 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -18,6 +18,7 @@ "exit": "Wyjście", "pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść", "create": "Utwórz zadanie", + "createAction": "Utwórz", "directDownload": "Pobieranie bezpośrednie", "advancedOptions": "Zaawansowane", "followSettings": "Podążaj za ustawieniami", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 8447f830..39b3a81a 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -18,6 +18,7 @@ "exit": "Sair", "pressBackAgainToExit": "Prima novamente Voltar para sair", "create": "Criar Tarefa", + "createAction": "Criar", "directDownload": "Download Direto", "advancedOptions": "Opções Avançadas", "followSettings": "Seguir as Configurações", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 1cc5fe24..782a5c9e 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -18,6 +18,7 @@ "exit": "Выход", "pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти", "create": "Создать задачу", + "createAction": "Создать", "directDownload": "Прямая загрузка", "advancedOptions": "Расширенные опции", "followSettings": "По умолчанию", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 82870d83..00d06c0c 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -18,6 +18,7 @@ "exit": "வெளியேறு", "pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்", "create": "பணியை உருவாக்கு", + "createAction": "உருவாக்கு", "directDownload": "நேரடி பதிவிறக்கம்", "advancedOptions": "மேம்பட்ட விருப்பங்கள்", "followSettings": "அமைப்புகளைப் பின்தொடரவும்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index ca979cae..a753215d 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -18,6 +18,7 @@ "exit": "Çıkış", "pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın", "create": "İşlem Oluştur", + "createAction": "Oluştur", "directDownload": "Direkt İndir", "advancedOptions": "Gelişmiş Seçenekler", "followSettings": "Ayarları Takip Et", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index ceae4052..82c6cc34 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -18,6 +18,7 @@ "exit": "Вихід", "pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти", "create": "Створити завдання", + "createAction": "Створити", "directDownload": "Пряме завантаження", "advancedOptions": "Додаткові параметри", "followSettings": "За замовчуванням", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 6ed35bd1..ccd43cb7 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -18,6 +18,7 @@ "exit": "Thoát", "pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát", "create": "Tạo nhiệm vụ", + "createAction": "Tạo", "directDownload": "Tải trực tiếp", "advancedOptions": "Tùy chọn nâng cao", "followSettings": "Theo thiết lập", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 96e8a184..ddc6e16d 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -18,6 +18,7 @@ "exit": "退出", "pressBackAgainToExit": "再按一次返回键退出应用", "create": "创建任务", + "createAction": "创建", "directDownload": "直接下载", "advancedOptions": "高级选项", "followSettings": "跟随设置", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index c75a84c7..db5bb6a2 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -18,6 +18,7 @@ "exit": "退出", "pressBackAgainToExit": "再按一次返回鍵退出應用程式", "create": "建立任務", + "createAction": "建立", "directDownload": "直接下載", "advancedOptions": "進階選項", "followSettings": "跟隨設定", diff --git a/ui/flutter/lib/shared/theme/app_component_themes.dart b/ui/flutter/lib/shared/theme/app_component_themes.dart index 5b0915a0..bc6611db 100644 --- a/ui/flutter/lib/shared/theme/app_component_themes.dart +++ b/ui/flutter/lib/shared/theme/app_component_themes.dart @@ -18,42 +18,50 @@ class AppComponentThemes extends StatelessWidget { align: 1, border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1), ), - child: shad.ComponentTheme( - data: shad.SecondaryButtonTheme( - decoration: (context, states, value) { - final decoration = switch (value) { - final BoxDecoration decoration => decoration, - _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), - }; - if (states.contains(WidgetState.disabled)) { - return decoration.copyWith(color: palette.surfaceSoft); - } - if (theme.brightness == Brightness.light) { - final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); - final hoverBackground = Color.alphaBlend( - palette.textPrimary.withValues(alpha: 0.13), - palette.surfaceSoft, - ); - return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); - } - return value; - }, - textStyle: (context, states, value) => states.contains(WidgetState.disabled) - ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) - : value, - iconTheme: (context, states, value) => states.contains(WidgetState.disabled) - ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) - : value, + child: shad.ComponentTheme( + data: shad.TextFieldTheme( + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + padding: AppDesignTokens.textFieldPadding, ), - child: shad.ComponentTheme( - data: shad.CheckboxTheme( - size: AppDesignTokens.checkboxSize, - gap: AppDesignTokens.checkboxLabelGap, - borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + child: shad.ComponentTheme( + data: shad.SecondaryButtonTheme( + decoration: (context, states, value) { + final decoration = switch (value) { + final BoxDecoration decoration => decoration, + _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), + }; + if (states.contains(WidgetState.disabled)) { + return decoration.copyWith(color: palette.surfaceSoft); + } + if (theme.brightness == Brightness.light) { + final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); + final hoverBackground = Color.alphaBlend( + palette.textPrimary.withValues(alpha: 0.13), + palette.surfaceSoft, + ); + return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); + } + return value; + }, + textStyle: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, + iconTheme: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, ), - child: shad.ComponentTheme( - data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), - child: child, + child: shad.ComponentTheme( + data: shad.CheckboxTheme( + size: AppDesignTokens.checkboxSize, + gap: AppDesignTokens.checkboxLabelGap, + borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + ), + child: shad.ComponentTheme( + data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), + child: child, + ), ), ), ), diff --git a/ui/flutter/lib/shared/theme/app_design_tokens.dart b/ui/flutter/lib/shared/theme/app_design_tokens.dart index 7847316d..615fb139 100644 --- a/ui/flutter/lib/shared/theme/app_design_tokens.dart +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -10,6 +10,7 @@ class AppDesignTokens { static const double contentHeaderHeight = 48; static const double taskRowHeight = 72; static const double controlRadius = 4; + static const double dialogActionMinWidth = 100; static const double checkboxSize = 18; static const double checkboxLabelGap = 10; static const double taskDetailsDrawerMinWidth = 440; @@ -21,6 +22,9 @@ class AppDesignTokens { static const double settingsFormControlWidth = 420; static const double settingsNumberControlWidth = 168; static const double settingsItemMinLabelWidth = 160; + static const EdgeInsets textFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 11); + static const EdgeInsets compactTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 6); + static const EdgeInsets numberTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 10); static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32); static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16); diff --git a/ui/flutter/lib/shared/widgets/app_loading_button.dart b/ui/flutter/lib/shared/widgets/app_loading_button.dart index b203ed33..f886204a 100644 --- a/ui/flutter/lib/shared/widgets/app_loading_button.dart +++ b/ui/flutter/lib/shared/widgets/app_loading_button.dart @@ -30,7 +30,15 @@ class AppLoadingButton extends StatelessWidget { final palette = AppPalette.of(context); final replaceChild = loading && icon == null; final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon; - final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child; + final effectiveChild = replaceChild + ? Stack( + alignment: Alignment.center, + children: [ + ExcludeSemantics(child: Opacity(opacity: 0, child: child)), + Positioned.fill(child: _LoadingIcon(variant: variant)), + ], + ) + : child; final content = leading == null ? effectiveChild : Row( diff --git a/ui/flutter/lib/shared/widgets/app_number_input.dart b/ui/flutter/lib/shared/widgets/app_number_input.dart index cda9545c..70d4bb52 100644 --- a/ui/flutter/lib/shared/widgets/app_number_input.dart +++ b/ui/flutter/lib/shared/widgets/app_number_input.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../theme/app_design_tokens.dart'; import 'app_text_field.dart'; /// A bounded numeric text field with the app's standard spinner controls. @@ -91,6 +92,7 @@ class _AppNumberInputState extends State { : TextInputType.number, inputFormatters: formatters, enabled: widget.enabled, + padding: AppDesignTokens.numberTextFieldPadding, features: [ shad.InputFeature.spinner( step: widget.step, diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index 5ae33c45..8b05f599 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -2,8 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/widgets.dart' as flutter show Row; -import 'package:flutter/foundation.dart' - show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart' show Icons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,6 +11,8 @@ import 'package:gopeed/api/model/create_task.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resolve_result.dart'; +import 'package:gopeed/api/model/resource.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/core/capabilities/capability_rpc.dart'; @@ -23,188 +24,148 @@ import 'package:gopeed/shared/services/download_directory_picker.dart'; import 'package:gopeed/shared/theme/app_component_themes.dart'; import 'package:gopeed/shared/theme/app_design_tokens.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { for (final navigationFails in [false, true]) { - testWidgets( - 'child creation selects downloads before closing (navigation fails: $navigationFails)', - (tester) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final events = []; - const channel = MethodChannel('window_manager'); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler(channel, (call) async { - events.add(call.method); - return null; + testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final events = []; + const channel = MethodChannel('window_manager'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + events.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind(GopeedMethods.createTask, (_) { + events.add('created'); + return 'task-id'; + }) + ..bind(NavigationMethods.showDownloadingTasks, (_) { + events.add('downloads'); + if (navigationFails) throw StateError('Navigation unavailable'); + return const RpcUnit(); }); - addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); - final config = DownloaderConfig(downloadDir: '/downloads') - ..extra.defaultDirectDownload = true; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => config) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) - ..bind(GopeedMethods.createTask, (_) { - events.add('created'); - return 'task-id'; - }) - ..bind(NavigationMethods.showDownloadingTasks, (_) { - events.add('downloads'); - if (navigationFails) throw StateError('Navigation unavailable'); - return const RpcUnit(); - }); - await tester.pumpWidget( - ProviderScope( - overrides: [ - appCapabilitiesProvider.overrideWithValue( - AppCapabilities(LocalCapabilityInvoker(registry)), - ), - ], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: AppComponentThemes( - child: CreateTaskWindowPage( - windowController: WindowController.fromWindowId('child'), - initialTask: CreateTask( - req: Request(url: 'https://example.com/file.apk'), - ), - ), + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: CreateTaskWindowPage( + windowController: WindowController.fromWindowId('child'), + initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')), ), ), ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Confirm')); - // The mocked native close deliberately leaves the widget mounted. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - expect(events, ['created', 'downloads', 'close']); - await tester.pumpWidget(const SizedBox()); - }, - ); + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirm')); + // The mocked native close deliberately leaves the widget mounted. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(events, ['created', 'downloads', 'close']); + await tester.pumpWidget(const SizedBox()); + }); } - testWidgets( - 'repeated external tasks update the open create route and replace request metadata', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - tester.view.physicalSize = const Size(700, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - CreateTask? submitted; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind( - GopeedMethods.getConfig, - (_) => DownloaderConfig(downloadDir: '/downloads'), - ) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-id'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final container = ProviderContainer( - overrides: [ - appCapabilitiesProvider.overrideWithValue( - AppCapabilities(LocalCapabilityInvoker(registry)), - ), - ], - ); - addTearDown(container.dispose); - final pending = container.read(pendingCreateTaskProvider.notifier); - pending.set( - CreateTask( - req: Request( - url: 'https://example.com/first.zip', - rawUrl: 'https://example.com/first', - labels: {'source': 'first'}, - extra: ReqExtraHttp( - method: 'POST', - body: 'old-body', - header: {'Cookie': 'old-cookie'}, - ).toJson(), - proxy: RequestProxy( - mode: RequestProxyMode.custom, - host: 'localhost:8080', - ), - skipVerifyCert: true, - ), + CreateTask? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + final pending = container.read(pendingCreateTaskProvider.notifier); + pending.set( + CreateTask( + req: Request( + url: 'https://example.com/first.zip', + rawUrl: 'https://example.com/first', + labels: {'source': 'first'}, + extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(), + proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'), + skipVerifyCert: true, ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute( - path: '/create', - builder: (_, _) => const CreateTaskWindowPage(), - ), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - routerConfig: router, - builder: (_, child) => AppComponentThemes(child: child!), - ), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), ), - ); + ), + ); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip'); + expect(container.read(pendingCreateTaskProvider), isNull); + final pageState = tester.state(find.byType(CreateTaskWindowPage)); + + for (final url in [ + 'https://example.com/second.zip', + 'https://example.com/third.zip', + 'https://example.com/third.zip', + ]) { + pending.set(CreateTask(req: Request(url: url))); + router.go('/create'); await tester.pumpAndSettle(); - expect( - _fieldText(tester, 'create-task-url-input'), - 'https://example.com/first.zip', - ); + expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState)); + expect(_fieldText(tester, 'create-task-url-input'), url); expect(container.read(pendingCreateTaskProvider), isNull); - final pageState = tester.state(find.byType(CreateTaskWindowPage)); - - for (final url in [ - 'https://example.com/second.zip', - 'https://example.com/third.zip', - 'https://example.com/third.zip', - ]) { - pending.set(CreateTask(req: Request(url: url))); - router.go('/create'); - await tester.pumpAndSettle(); - expect( - tester.state(find.byType(CreateTaskWindowPage)), - same(pageState), - ); - expect(_fieldText(tester, 'create-task-url-input'), url); - expect(container.read(pendingCreateTaskProvider), isNull); - } - await tester.tap( - find.byKey(const ValueKey('create-task-direct-download-toggle')), - ); - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - expect(submitted?.req?.url, 'https://example.com/third.zip'); - expect(submitted?.req?.rawUrl, isNull); - expect(submitted?.req?.labels, isNull); - expect(submitted?.req?.extra, isNull); - expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); - expect(submitted?.req?.skipVerifyCert, isFalse); - expect(tester.takeException(), isNull); - } finally { - debugDefaultTargetPlatformOverride = null; } - }, - ); + await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle'))); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted?.req?.url, 'https://example.com/third.zip'); + expect(submitted?.req?.rawUrl, isNull); + expect(submitted?.req?.labels, isNull); + expect(submitted?.req?.extra, isNull); + expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); + expect(submitted?.req?.skipVerifyCert, isFalse); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); - testWidgets('mobile create task page starts below the system status bar', ( - WidgetTester tester, - ) async { + testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); tester.view.devicePixelRatio = 1; @@ -214,10 +175,7 @@ void main() { addTearDown(tester.view.resetPadding); final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind( - GopeedMethods.getConfig, - (_) => DownloaderConfig(downloadDir: '/downloads'), - ); + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); try { @@ -233,573 +191,421 @@ void main() { ); await tester.pumpAndSettle(); - final contentPadding = tester.widget( - find.byKey(const ValueKey('create-task-safe-content')), - ); + final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); expect((contentPadding.padding as EdgeInsets).top, 32); } finally { debugDefaultTargetPlatformOverride = null; } }); - testWidgets( - 'browser extension request parameters populate the form and survive submission', - (WidgetTester tester) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; - final androidLocations = { - 'application': 'D:/Downloads', - 'downloads': 'G:/Manually selected', - }; - DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => - androidLocations; - DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; - addTearDown(() { - DownloadDirectoryPicker.debugPlatformOverride = null; - DownloadDirectoryPicker.debugAndroidLocationsLoader = null; - DownloadDirectoryPicker.debugDownloadsPreparer = null; + testWidgets('browser extension request parameters populate the form and survive submission', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + final androidLocations = {'application': 'D:/Downloads', 'downloads': 'G:/Manually selected'}; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => androidLocations; + DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; + addTearDown(() { + DownloadDirectoryPicker.debugPlatformOverride = null; + DownloadDirectoryPicker.debugAndroidLocationsLoader = null; + DownloadDirectoryPicker.debugDownloadsPreparer = null; + }); + + CreateTask? submitted; + final config = DownloaderConfig(downloadDir: 'C:/Downloads') + ..extra.defaultDirectDownload = true + ..extra.downloadCategories = [ + DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), + DownloadCategory(name: 'Archives', path: 'F:/Archives'), + DownloadCategory(name: 'Long video category', path: 'G:/Videos'), + DownloadCategory(name: 'Long document category', path: 'H:/Documents'), + ] + ..protocolConfig.http.connections = 8; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-task'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + final initialTask = CreateTask( + req: Request( + rawUrl: 'https://page.example/download', + url: 'https://cdn.example/archive.zip', + extra: ReqExtraHttp( + method: 'POST', + header: const { + 'User-Agent': 'Browser UA', + 'Cookie': 'session=abc', + 'Referer': 'https://page.example/', + 'Authorization': 'Bearer token', + }, + body: 'request-body', + ).toJson(), + labels: const {'source': 'browser-extension'}, + proxy: RequestProxy( + mode: RequestProxyMode.custom, + scheme: 'socks5', + host: '127.0.0.1:7890', + usr: 'proxy-user', + pwd: 'proxy-password', + ), + skipVerifyCert: true, + ), + opts: Options( + name: 'archive.zip', + path: 'D:/Downloads', + extra: OptsExtraHttp( + connections: 12, + autoTorrent: true, + deleteTorrentAfterDownload: true, + autoExtract: true, + archivePassword: 'archive-password', + deleteAfterExtract: true, + ).toJson(), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/create', + builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); + + final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); + expect(connectionsInput, findsOneWidget); + expect( + tester.getSize(connectionsInput).width, + tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width, + ); + final connectionsField = tester.widget(connectionsInput); + expect(connectionsField.controller!.text, '12'); + expect(connectionsField.features.single, isA()); + + final advancedSection = find.byKey(const ValueKey('create-task-advanced-section')); + expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showSecond); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showFirst); + + expect(_fieldText(tester, 'create-task-http-header-name-0'), 'User-Agent'); + expect(_fieldText(tester, 'create-task-http-header-value-0'), 'Browser UA'); + expect(_fieldText(tester, 'create-task-http-header-name-3'), 'Authorization'); + expect(_fieldText(tester, 'create-task-http-header-value-3'), 'Bearer token'); + expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); + expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); + expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); + expect(_fieldText(tester, 'create-task-proxy-password'), 'proxy-password'); + expect(_fieldText(tester, 'create-task-archive-password'), 'archive-password'); + expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-auto-torrent')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-delete-torrent')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-delete-after-extract')), findsOneWidget); + + expect(find.text('Archives'), findsNWidgets(2)); + expect(find.text('Quick folders'), findsNothing); + expect(find.byKey(const ValueKey('create-task-category-0')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-1')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-2')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-3')), findsOneWidget); + final shortcutRow = find.byKey(const ValueKey('create-task-directory-shortcuts')); + expect(tester.getSize(shortcutRow).height, 28); + final rememberDirectory = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); + final directDownloadCheckbox = find.byKey(const ValueKey('create-task-direct-download-checkbox')); + final rememberDirectoryRow = find.byKey(const ValueKey('create-task-remember-download-directory')); + final rememberDirectoryLabel = find.byKey(const ValueKey('create-task-remember-download-directory-label')); + final directDownloadLabel = find.descendant( + of: find.byKey(const ValueKey('create-task-direct-download-toggle')), + matching: find.text('Skip resolving and create tasks immediately'), + ); + final directoryComponent = find.byKey(const ValueKey('create-task-directory-component')); + final directoryOptionsRow = find.byKey(const ValueKey('create-task-directory-options-row')); + final directoryOptionsLayout = find.byKey(const ValueKey('create-task-directory-options-layout')); + final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker')); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect(find.text('Save as default download directory'), findsOneWidget); + final rememberStyle = tester.widget(rememberDirectoryLabel).style!; + final directStyle = tester.widget(directDownloadLabel).style!; + expect(rememberStyle.color, directStyle.color); + expect(rememberStyle.fontSize, directStyle.fontSize); + expect(rememberStyle.fontWeight, directStyle.fontWeight); + expect(tester.widget(rememberDirectory).size, isNull); + expect(tester.getSize(rememberDirectory), const Size.square(AppDesignTokens.checkboxSize)); + expect(tester.getCenter(rememberDirectory).dx, closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5)); + final rememberDirectoryText = tester.widget(rememberDirectoryLabel); + final directDownloadText = tester.widget(directDownloadLabel); + expect(rememberDirectoryText.style, directDownloadText.style); + expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); + expect(rememberDirectoryText.overflow, directDownloadText.overflow); + expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); + expect(tester.widget(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween); + expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5)); + expect(tester.getTopRight(directoryOptionsRow).dx, closeTo(tester.getTopRight(directoryComponent).dx, 0.5)); + expect( + tester.getTopRight(find.byKey(const ValueKey('create-task-category-3'))).dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + + tester.view.physicalSize = const Size(520, 720); + await tester.pumpAndSettle(); + final shortcutContent = find.byKey(const ValueKey('create-task-directory-shortcuts-content')); + expect(tester.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy)); + expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5)); + expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width)); + final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable)); + expect(tester.state(shortcutScrollable).position.maxScrollExtent, greaterThan(0)); + + tester.view.physicalSize = const Size(719, 720); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))), + isA(), + ); + + tester.view.physicalSize = const Size(720, 720); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))).mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + + tester.view.physicalSize = const Size(1024, 720); + await tester.pumpAndSettle(); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); + + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); + await tester.pump(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect( + find.descendant( + of: find.byKey(const ValueKey('create-task-category-3')), + matching: find.byIcon(Icons.folder_outlined), + ), + findsOneWidget, + ); + + androidLocations['downloads'] = 'C:/Downloads'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + + androidLocations['downloads'] = 'G:/Manually selected'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); + + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + final request = submitted?.req; + expect(request, isNotNull); + expect(request?.rawUrl, initialTask.req?.rawUrl); + expect(request?.labels, initialTask.req?.labels); + expect(request?.proxy?.mode, RequestProxyMode.custom); + expect(request?.proxy?.scheme, 'socks5'); + expect(request?.proxy?.host, '127.0.0.1:7890'); + expect(request?.proxy?.usr, 'proxy-user'); + expect(request?.proxy?.pwd, 'proxy-password'); + expect(request?.skipVerifyCert, isTrue); + expect(ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), initialTask.req?.extra); + expect(submitted?.opts?.path, 'G:/Manually selected'); + final options = OptsExtraHttp.fromJson(submitted?.opts?.extra! as Map); + expect(options.connections, 12); + expect(options.autoTorrent, isTrue); + expect(options.deleteTorrentAfterDownload, isTrue); + expect(options.autoExtract, isTrue); + expect(options.archivePassword, 'archive-password'); + expect(options.deleteAfterExtract, isTrue); + expect(submitted?.opts?.asDefaultPath, isTrue); + }); + + testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async { + tester.view.physicalSize = const Size(700, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved-id', + res: Resource( + name: 'server-generated-name', + files: [FileInfo(name: 'video.mp4', size: 1024)], + ), + ), + ) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(_fieldText(tester, 'create-task-rename-input'), isEmpty); + final createButton = tester.widget(find.byKey(const ValueKey('resolve-create-button'))); + expect(createButton.child, isA()); + final createText = createButton.child as Text; + expect(createText.data, 'Create'); + expect(createText.maxLines, 1); + expect(createText.softWrap, isFalse); + expect( + tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))), + tester.getSize(find.byKey(const ValueKey('resolve-create-button'))), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final histories = [ + 'https://example.com/alpha.zip', + 'https://example.com/beta.iso', + 'magnet:?xt=urn:btih:gamma', + ]; + String? removedHistory; + var cleared = false; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(StorageMethods.getCreateHistory, (_) => List.unmodifiable(histories)) + ..bind(StorageMethods.removeCreateHistory, (value) { + removedHistory = value; + histories.remove(value); + return const RpcUnit(); + }) + ..bind(StorageMethods.clearCreateHistory, (_) { + cleared = true; + histories.clear(); + return const RpcUnit(); }); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - CreateTask? submitted; - final config = DownloaderConfig(downloadDir: 'C:/Downloads') - ..extra.defaultDirectDownload = true - ..extra.downloadCategories = [ - DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), - DownloadCategory(name: 'Archives', path: 'F:/Archives'), - DownloadCategory(name: 'Long video category', path: 'G:/Videos'), - DownloadCategory( - name: 'Long document category', - path: 'H:/Documents', - ), - ] - ..protocolConfig.http.connections = 8; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => config) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-task'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - - final initialTask = CreateTask( - req: Request( - rawUrl: 'https://page.example/download', - url: 'https://cdn.example/archive.zip', - extra: ReqExtraHttp( - method: 'POST', - header: const { - 'User-Agent': 'Browser UA', - 'Cookie': 'session=abc', - 'Referer': 'https://page.example/', - 'Authorization': 'Bearer token', - }, - body: 'request-body', - ).toJson(), - labels: const {'source': 'browser-extension'}, - proxy: RequestProxy( - mode: RequestProxyMode.custom, - scheme: 'socks5', - host: '127.0.0.1:7890', - usr: 'proxy-user', - pwd: 'proxy-password', - ), - skipVerifyCert: true, + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), ), - opts: Options( - name: 'archive.zip', - path: 'D:/Downloads', - extra: OptsExtraHttp( - connections: 12, - autoTorrent: true, - deleteTorrentAfterDownload: true, - autoExtract: true, - archivePassword: 'archive-password', - deleteAfterExtract: true, - ).toJson(), - ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute( - path: '/create', - builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), - ), - ], - ); - addTearDown(router.dispose); + ), + ); + await tester.pumpAndSettle(); - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - builder: (context, child) => - AppComponentThemes(child: child ?? const SizedBox.shrink()), - routerConfig: router, - ), - ), - ); - await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.history)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, + tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, + ); + final alphaItem = find.byKey(const ValueKey('create-history-item-https://example.com/alpha.zip')); + final betaItem = find.byKey(const ValueKey('create-history-item-https://example.com/beta.iso')); + final gammaItem = find.byKey(const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma')); + expect(alphaItem, findsOneWidget); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + final clearAction = find.byKey(const ValueKey('create-history-clear')); + final closeAction = find.byKey(const ValueKey('create-history-close')); + expect( + tester.widget(find.ancestor(of: clearAction, matching: find.byType(AppTooltip))).message, + 'Clear History', + ); + expect( + tester.widget(find.ancestor(of: closeAction, matching: find.byType(AppTooltip))).message, + 'Close', + ); + final historyText = tester.widget(find.descendant(of: alphaItem, matching: find.byType(Text))); + expect(historyText.data, contains('\u200B')); + expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); - final connectionsInput = find.byKey( - const ValueKey('create-task-connections-input'), - ); - expect(connectionsInput, findsOneWidget); - expect( - tester.getSize(connectionsInput).width, - AppDesignTokens.settingsNumberControlWidth, - ); - final connectionsField = tester.widget(connectionsInput); - expect(connectionsField.controller!.text, '12'); - expect(connectionsField.features.single, isA()); + final filter = find.byKey(const ValueKey('create-history-filter')); + await tester.enterText(filter, 'beta'); + await tester.pump(); + expect(alphaItem, findsNothing); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsNothing); - final advancedSection = find.byKey( - const ValueKey('create-task-advanced-section'), - ); - expect( - tester.widget(advancedSection).crossFadeState, - CrossFadeState.showSecond, - ); - await tester.tap(find.text('Advanced')); - await tester.pumpAndSettle(); - expect( - tester.widget(advancedSection).crossFadeState, - CrossFadeState.showFirst, - ); + await tester.tap(find.byKey(const ValueKey('create-history-delete-https://example.com/beta.iso'))); + await tester.pumpAndSettle(); + expect(removedHistory, 'https://example.com/beta.iso'); + expect(find.text('No History Found'), findsOneWidget); - expect( - _fieldText(tester, 'create-task-http-header-name-0'), - 'User-Agent', - ); - expect( - _fieldText(tester, 'create-task-http-header-value-0'), - 'Browser UA', - ); - expect( - _fieldText(tester, 'create-task-http-header-name-3'), - 'Authorization', - ); - expect( - _fieldText(tester, 'create-task-http-header-value-3'), - 'Bearer token', - ); - expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); - expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); - expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); - expect( - _fieldText(tester, 'create-task-proxy-password'), - 'proxy-password', - ); - expect( - _fieldText(tester, 'create-task-archive-password'), - 'archive-password', - ); - expect( - find.byKey(const ValueKey('create-task-skip-verify-cert')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-auto-torrent')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-delete-torrent')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-auto-extract')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-delete-after-extract')), - findsOneWidget, - ); + await tester.enterText(filter, ''); + await tester.pump(); + expect(alphaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); - expect(find.text('Archives'), findsNWidgets(2)); - expect(find.text('Quick folders'), findsNothing); - expect( - find.byKey(const ValueKey('create-task-category-0')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-category-1')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-category-2')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-category-3')), - findsOneWidget, - ); - final shortcutRow = find.byKey( - const ValueKey('create-task-directory-shortcuts'), - ); - expect(tester.getSize(shortcutRow).height, 28); - final rememberDirectory = find.byKey( - const ValueKey('create-task-remember-download-directory-checkbox'), - ); - final directDownloadCheckbox = find.byKey( - const ValueKey('create-task-direct-download-checkbox'), - ); - final rememberDirectoryRow = find.byKey( - const ValueKey('create-task-remember-download-directory'), - ); - final rememberDirectoryLabel = find.byKey( - const ValueKey('create-task-remember-download-directory-label'), - ); - final directDownloadLabel = find.descendant( - of: find.byKey(const ValueKey('create-task-direct-download-toggle')), - matching: find.text('Skip resolving and create tasks immediately'), - ); - final directoryComponent = find.byKey( - const ValueKey('create-task-directory-component'), - ); - final directoryOptionsRow = find.byKey( - const ValueKey('create-task-directory-options-row'), - ); - final directoryOptionsLayout = find.byKey( - const ValueKey('create-task-directory-options-layout'), - ); - final directoryPicker = find.byKey( - const ValueKey('create-task-directory-picker'), - ); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - expect(find.text('Save as default download directory'), findsOneWidget); - final rememberStyle = tester.widget(rememberDirectoryLabel).style!; - final directStyle = tester.widget(directDownloadLabel).style!; - expect(rememberStyle.color, directStyle.color); - expect(rememberStyle.fontSize, directStyle.fontSize); - expect(rememberStyle.fontWeight, directStyle.fontWeight); - expect(tester.widget(rememberDirectory).size, isNull); - expect( - tester.getSize(rememberDirectory), - const Size.square(AppDesignTokens.checkboxSize), - ); - expect( - tester.getCenter(rememberDirectory).dx, - closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5), - ); - final rememberDirectoryText = tester.widget(rememberDirectoryLabel); - final directDownloadText = tester.widget(directDownloadLabel); - expect(rememberDirectoryText.style, directDownloadText.style); - expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); - expect(rememberDirectoryText.overflow, directDownloadText.overflow); - expect( - tester.widget(directoryPicker).variance, - same(shad.ButtonVariance.outline), - ); - expect( - tester.widget(directoryOptionsLayout).mainAxisAlignment, - MainAxisAlignment.spaceBetween, - ); - expect( - tester.getCenter(rememberDirectoryRow).dy, - closeTo(tester.getCenter(shortcutRow).dy, 0.5), - ); - expect( - tester.getTopRight(directoryOptionsRow).dx, - closeTo(tester.getTopRight(directoryComponent).dx, 0.5), - ); - expect( - tester - .getTopRight(find.byKey(const ValueKey('create-task-category-3'))) - .dx, - closeTo(tester.getTopRight(directoryComponent).dx, 0.5), - ); - - tester.view.physicalSize = const Size(520, 720); - await tester.pumpAndSettle(); - final shortcutContent = find.byKey( - const ValueKey('create-task-directory-shortcuts-content'), - ); - expect( - tester.getTopLeft(shortcutRow).dy, - greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy), - ); - expect( - tester.getSize(shortcutRow).width, - closeTo(tester.getSize(directoryComponent).width, 0.5), - ); - expect( - tester.getSize(shortcutContent).width, - greaterThan(tester.getSize(shortcutRow).width), - ); - final shortcutScrollable = find.descendant( - of: shortcutRow, - matching: find.byType(Scrollable), - ); - expect( - tester - .state(shortcutScrollable) - .position - .maxScrollExtent, - greaterThan(0), - ); - - tester.view.physicalSize = const Size(719, 720); - await tester.pumpAndSettle(); - expect( - tester.widget( - find.byKey(const ValueKey('create-task-directory-options-layout')), - ), - isA(), - ); - - tester.view.physicalSize = const Size(720, 720); - await tester.pumpAndSettle(); - expect( - tester - .widget( - find.byKey( - const ValueKey('create-task-directory-options-layout'), - ), - ) - .mainAxisAlignment, - MainAxisAlignment.spaceBetween, - ); - - tester.view.physicalSize = const Size(1024, 720); - await tester.pumpAndSettle(); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-app-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-downloads-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.checked, - ); - - expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); - await tester.pump(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - expect( - find.descendant( - of: find.byKey(const ValueKey('create-task-category-3')), - matching: find.byIcon(Icons.folder_outlined), - ), - findsOneWidget, - ); - - androidLocations['downloads'] = 'C:/Downloads'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-downloads-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - - androidLocations['downloads'] = 'G:/Manually selected'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-downloads-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.checked, - ); - - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - - final request = submitted?.req; - expect(request, isNotNull); - expect(request?.rawUrl, initialTask.req?.rawUrl); - expect(request?.labels, initialTask.req?.labels); - expect(request?.proxy?.mode, RequestProxyMode.custom); - expect(request?.proxy?.scheme, 'socks5'); - expect(request?.proxy?.host, '127.0.0.1:7890'); - expect(request?.proxy?.usr, 'proxy-user'); - expect(request?.proxy?.pwd, 'proxy-password'); - expect(request?.skipVerifyCert, isTrue); - expect( - ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), - initialTask.req?.extra, - ); - expect(submitted?.opts?.path, 'G:/Manually selected'); - final options = OptsExtraHttp.fromJson( - submitted?.opts?.extra! as Map, - ); - expect(options.connections, 12); - expect(options.autoTorrent, isTrue); - expect(options.deleteTorrentAfterDownload, isTrue); - expect(options.autoExtract, isTrue); - expect(options.archivePassword, 'archive-password'); - expect(options.deleteAfterExtract, isTrue); - expect(submitted?.opts?.asDefaultPath, isTrue); - }, - ); - - testWidgets( - 'create history supports filtering, deleting one entry, and clearing all entries', - (WidgetTester tester) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final histories = [ - 'https://example.com/alpha.zip', - 'https://example.com/beta.iso', - 'magnet:?xt=urn:btih:gamma', - ]; - String? removedHistory; - var cleared = false; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind( - GopeedMethods.getConfig, - (_) => DownloaderConfig(downloadDir: '/downloads'), - ) - ..bind( - StorageMethods.getCreateHistory, - (_) => List.unmodifiable(histories), - ) - ..bind(StorageMethods.removeCreateHistory, (value) { - removedHistory = value; - histories.remove(value); - return const RpcUnit(); - }) - ..bind(StorageMethods.clearCreateHistory, (_) { - cleared = true; - histories.clear(); - return const RpcUnit(); - }); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: AppComponentThemes(child: const CreateTaskWindowPage()), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.history)); - await tester.pumpAndSettle(); - expect( - tester - .getSize(find.byKey(const ValueKey('create-history-title-bar'))) - .width, - tester - .getSize(find.byKey(const ValueKey('create-history-content'))) - .width, - ); - final alphaItem = find.byKey( - const ValueKey('create-history-item-https://example.com/alpha.zip'), - ); - final betaItem = find.byKey( - const ValueKey('create-history-item-https://example.com/beta.iso'), - ); - final gammaItem = find.byKey( - const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma'), - ); - expect(alphaItem, findsOneWidget); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); - final clearAction = find.byKey(const ValueKey('create-history-clear')); - final closeAction = find.byKey(const ValueKey('create-history-close')); - expect( - tester - .widget( - find.ancestor(of: clearAction, matching: find.byType(AppTooltip)), - ) - .message, - 'Clear History', - ); - expect( - tester - .widget( - find.ancestor(of: closeAction, matching: find.byType(AppTooltip)), - ) - .message, - 'Close', - ); - final historyText = tester.widget( - find.descendant(of: alphaItem, matching: find.byType(Text)), - ); - expect(historyText.data, contains('\u200B')); - expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); - - final filter = find.byKey(const ValueKey('create-history-filter')); - await tester.enterText(filter, 'beta'); - await tester.pump(); - expect(alphaItem, findsNothing); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsNothing); - - await tester.tap( - find.byKey( - const ValueKey('create-history-delete-https://example.com/beta.iso'), - ), - ); - await tester.pumpAndSettle(); - expect(removedHistory, 'https://example.com/beta.iso'); - expect(find.text('No History Found'), findsOneWidget); - - await tester.enterText(filter, ''); - await tester.pump(); - expect(alphaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('create-history-clear'))); - await tester.pumpAndSettle(); - expect(cleared, isTrue); - expect(find.text('No history'), findsOneWidget); - expect( - find.byKey(const ValueKey('create-history-clear')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-history-close')), - findsOneWidget, - ); - }, - ); + await tester.tap(find.byKey(const ValueKey('create-history-clear'))); + await tester.pumpAndSettle(); + expect(cleared, isTrue); + expect(find.text('No history'), findsOneWidget); + expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); + expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); + }); } String _fieldText(WidgetTester tester, String key) { - final editable = find.descendant( - of: find.byKey(ValueKey(key)), - matching: find.byType(EditableText), - ); + final editable = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(EditableText)); return tester.widget(editable).controller.text; } diff --git a/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart new file mode 100644 index 00000000..4a8d5940 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/resolve_task_actions.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final width in [390.0, 1024.0]) { + for (final language in ['en', 'zh', 'de']) { + testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async { + tester.view.physicalSize = Size(width, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var submitting = false; + var cancelled = false; + await tester.pumpWidget( + shad.ShadcnApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Localizations.override( + context: context, + locale: Locale(language), + delegates: const [AppLocalizations.delegate], + child: AppComponentThemes( + child: Center( + child: StatefulBuilder( + builder: (context, setState) => shad.AlertDialog( + padding: const EdgeInsets.all(18), + content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100), + actions: [ + Flexible( + child: ResolveTaskActions( + submitting: submitting, + onCancel: () => cancelled = true, + onCreate: () => setState(() => submitting = true), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final cancel = find.byKey(const ValueKey('resolve-cancel-button')); + final create = find.byKey(const ValueKey('resolve-create-button')); + final cancelRect = tester.getRect(cancel); + final createRect = tester.getRect(create); + expect(cancelRect.size, createRect.size); + expect(cancelRect.center.dy, createRect.center.dy); + expect(cancelRect.left, greaterThanOrEqualTo(0)); + expect(createRect.right, lessThanOrEqualTo(width)); + expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget); + await tester.tap(cancel); + expect(cancelled, isTrue); + await tester.tap(create); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.widget(create).loading, isTrue); + expect(tester.widget(cancel).onPressed, isNull); + expect(tester.getRect(cancel), cancelRect); + expect(tester.getRect(create), createRect); + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/ui/flutter/test/shared/theme/app_component_themes_test.dart b/ui/flutter/test/shared/theme/app_component_themes_test.dart index 9080b1e2..b78b0235 100644 --- a/ui/flutter/test/shared/theme/app_component_themes_test.dart +++ b/ui/flutter/test/shared/theme/app_component_themes_test.dart @@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { + testWidgets('provides the standard text field appearance', (tester) async { + late shad.TextFieldTheme textFieldTheme; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) { + textFieldTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(textFieldTheme.filled, isTrue); + expect(textFieldTheme.border, Border.all(color: AppPalette.light.border)); + expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius)); + expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding); + }); + testWidgets('provides the standard checkbox metrics', (tester) async { late shad.CheckboxTheme checkboxTheme; diff --git a/ui/flutter/test/shared/widgets/app_loading_button_test.dart b/ui/flutter/test/shared/widgets/app_loading_button_test.dart index 1726cfd6..8d3f1ded 100644 --- a/ui/flutter/test/shared/widgets/app_loading_button_test.dart +++ b/ui/flutter/test/shared/widgets/app_loading_button_test.dart @@ -134,7 +134,10 @@ void main() { of: find.byKey(const ValueKey('text-only-loading-button')), matching: find.byType(shad.CircularProgressIndicator), ); - expect(find.text('Login'), findsNothing); + final hiddenLabel = find.text('Login'); + expect(hiddenLabel, findsOneWidget); + expect(tester.widget(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0); + expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget); expect(loader, findsOneWidget); expect(tester.getRect(loader).center, bounds.center); }); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index fc18a5a5..b90329cc 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -449,7 +449,7 @@ void main() { child: shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + home: const AppComponentThemes(child: ExtensionsPage()), ), ), ); @@ -469,6 +469,7 @@ void main() { final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0'))); final rightmostCardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))); final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark'))); + expect(searchRect.height, closeTo(sortRect.height, 0.01)); expect(searchRect.width, 240); expect(searchRect.width, lessThan(cardRect.width)); expect(sortRect.left - searchRect.right, closeTo(10, 0.01)); @@ -501,6 +502,7 @@ void main() { expect(find.byType(PrimaryRail), findsOneWidget); final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container'))); expect(compactSearchRect.width, lessThan(240)); + expect(compactSearchRect.height, closeTo(sortRect.height, 0.01)); expect( tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).center.dy, closeTo(compactSearchRect.center.dy, 0.01), @@ -745,7 +747,7 @@ void main() { child: shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + home: const AppComponentThemes(child: ExtensionsPage()), ), ), ); @@ -1895,6 +1897,40 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('loading button preserves its content dimensions', (WidgetTester tester) async { + var loading = false; + late StateSetter setButtonState; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Center( + child: StatefulBuilder( + builder: (context, update) { + setButtonState = update; + return AppLoadingButton( + key: const ValueKey('dimension-stable-loading-button'), + onPressed: () {}, + loading: loading, + variant: AppLoadingButtonVariant.primary, + child: const Text('Confirm'), + ); + }, + ), + ), + ), + ); + await tester.pump(); + final button = find.byKey(const ValueKey('dimension-stable-loading-button')); + final idleSize = tester.getSize(button); + + setButtonState(() => loading = true); + await tester.pump(); + + expect(tester.getSize(button), idleSize); + expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + }); + testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async { await _setTestSize(tester, const Size(1024, 900)); final runtimeController = FailingTrackerRuntimeController(); @@ -2337,7 +2373,9 @@ void main() { await tester.pumpAndSettle(); final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input')); final createRenameInput = find.byKey(const ValueKey('create-task-rename-input')); + final createConnectionsInput = find.byKey(const ValueKey('create-task-connections-input')); expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height); + expect(tester.getSize(createConnectionsInput), tester.getSize(createRenameInput)); final createDirectoryField = tester.widget(createDirectoryInput); final createRenameField = tester.widget( find.descendant(of: createRenameInput, matching: find.byType(AppTextField)), @@ -2439,6 +2477,27 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('create task rename stays user-controlled and resets when the URL changes', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 500)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pump(); + + final urlInput = find.byKey(const ValueKey('create-task-url-input')); + final renameInput = find.byKey(const ValueKey('create-task-rename-input')); + await tester.enterText(urlInput, 'https://example.com/first.zip'); + await tester.enterText(renameInput, 'custom-name.zip'); + expect(find.text('custom-name.zip'), findsOneWidget); + + await tester.enterText(urlInput, 'https://example.com/second.zip'); + await tester.pump(); + + final renameField = tester.widget( + find.descendant(of: renameInput, matching: find.byType(AppTextField)), + ); + expect(renameField.controller!.text, isEmpty); + await tester.pump(const Duration(seconds: 1)); + }); + testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async { await _setTestSize(tester, const Size(700, 500)); await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); @@ -2626,6 +2685,7 @@ void main() { expect(treeFinder, findsOneWidget); final tree = tester.widget>(treeFinder); expect(tree.branchLine, same(shad.BranchLine.path)); + expect(tree.rowHeight, 36); expect( tester.widgetList(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null), isTrue, @@ -4854,7 +4914,7 @@ class _CreateTaskPageHarness extends StatelessWidget { return shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const CreateTaskWindowPage(), + home: const AppComponentThemes(child: CreateTaskWindowPage()), ); } } From 5f89ae35164ee63f13604be428eb43ab9d7a862f Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:03:43 +0800 Subject: [PATCH 3/6] fix: unify platform window titles as Gopeed (#1494) --- ui/flutter/lib/app/app.dart | 2 +- ui/flutter/lib/core/window/app_window_bootstrap.dart | 5 +++-- ui/flutter/linux/runner/my_application.cc | 4 ++-- ui/flutter/macos/Runner/Base.lproj/MainMenu.xib | 2 +- ui/flutter/test/core/window/app_window_payload_test.dart | 4 ++-- ui/flutter/web/manifest.json | 4 ++-- ui/flutter/windows/runner/main.cpp | 4 ++-- 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ui/flutter/lib/app/app.dart b/ui/flutter/lib/app/app.dart index 0a0e3b33..33531d28 100644 --- a/ui/flutter/lib/app/app.dart +++ b/ui/flutter/lib/app/app.dart @@ -79,7 +79,7 @@ class _GopeedAppState extends ConsumerState with WidgetsBindingObserv return shad.ShadcnApp.router( debugShowCheckedModeBanner: false, disableBrowserContextMenu: false, - onGenerateTitle: (context) => context.l10n.appTitle, + title: 'Gopeed', theme: AppTheme.light(themeColor), darkTheme: AppTheme.dark(themeColor), materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor), diff --git a/ui/flutter/lib/core/window/app_window_bootstrap.dart b/ui/flutter/lib/core/window/app_window_bootstrap.dart index 275e58c8..e2b29316 100644 --- a/ui/flutter/lib/core/window/app_window_bootstrap.dart +++ b/ui/flutter/lib/core/window/app_window_bootstrap.dart @@ -16,6 +16,7 @@ class AppWindowBootstrap { await windowManager.ensureInitialized(); final windowOptions = WindowOptions( + title: 'Gopeed', size: const Size(1024, 768), center: true, skipTaskbar: false, @@ -74,8 +75,8 @@ class AppWindowBootstrap { static String subWindowTitle(AppWindowType type, AppLocalizations l10n) { return switch (type) { - AppWindowType.createTask => '${l10n.create} - Gopeed', - _ => l10n.appTitle, + AppWindowType.createTask => 'Gopeed - ${l10n.create}', + _ => 'Gopeed', }; } diff --git a/ui/flutter/linux/runner/my_application.cc b/ui/flutter/linux/runner/my_application.cc index d6355099..895f8fd1 100644 --- a/ui/flutter/linux/runner/my_application.cc +++ b/ui/flutter/linux/runner/my_application.cc @@ -48,11 +48,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "gopeed"); + gtk_header_bar_set_title(header_bar, "Gopeed"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "gopeed"); + gtk_window_set_title(window, "Gopeed"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib index 80e867a4..2070a6c6 100644 --- a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib +++ b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib @@ -330,7 +330,7 @@ - + diff --git a/ui/flutter/test/core/window/app_window_payload_test.dart b/ui/flutter/test/core/window/app_window_payload_test.dart index 9b71c2b3..d4bdd090 100644 --- a/ui/flutter/test/core/window/app_window_payload_test.dart +++ b/ui/flutter/test/core/window/app_window_payload_test.dart @@ -9,9 +9,9 @@ void main() { test('create-task child window has a localized native title', () { expect( AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')), - 'Create Task - Gopeed', + 'Gopeed-Create Task', ); - expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed'); + expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务'); }); test('create-task payload only carries initial task data', () { diff --git a/ui/flutter/web/manifest.json b/ui/flutter/web/manifest.json index 3e285875..d75201fc 100644 --- a/ui/flutter/web/manifest.json +++ b/ui/flutter/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "gopeed", - "short_name": "gopeed", + "name": "Gopeed", + "short_name": "Gopeed", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/ui/flutter/windows/runner/main.cpp b/ui/flutter/windows/runner/main.cpp index c8c65677..74a39e96 100644 --- a/ui/flutter/windows/runner/main.cpp +++ b/ui/flutter/windows/runner/main.cpp @@ -56,7 +56,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); if (!IsMultiWindowLaunch(command_line_arguments) && - SendAppLinkToInstance(L"gopeed")) { + SendAppLinkToInstance(L"Gopeed")) { return EXIT_SUCCESS; } @@ -71,7 +71,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"gopeed", origin, size)) { + if (!window.Create(L"Gopeed", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); From 36f9f65384afef6e45b7906dfd9f9f8a31e2b126 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:05:15 +0800 Subject: [PATCH 4/6] fix(android): request notification permission on startup (#1497) --- .../lib/app/application/android_foreground_service.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/flutter/lib/app/application/android_foreground_service.dart b/ui/flutter/lib/app/application/android_foreground_service.dart index 479b7363..9efd4da8 100644 --- a/ui/flutter/lib/app/application/android_foreground_service.dart +++ b/ui/flutter/lib/app/application/android_foreground_service.dart @@ -28,6 +28,13 @@ class AndroidForegroundService { ), ); + // Android 13+ requires runtime permission to show the service notification. + // A denial only hides it from the notification drawer; the service can run. + final permission = await FlutterForegroundTask.checkNotificationPermission(); + if (permission == NotificationPermission.denied) { + await FlutterForegroundTask.requestNotificationPermission(); + } + if (await FlutterForegroundTask.isRunningService) { _throwOnFailure(await FlutterForegroundTask.restartService()); return; From 75ffe70a4d0e9cac1664c274a9053f4888ad3a70 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:50:09 +0800 Subject: [PATCH 5/6] fix(windows): use bundled PNG for notification header icon (#1499) --- .../app_notification_controller.dart | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 5ca4efb8..bd09d189 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -2,14 +2,14 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; import '../../core/common/task_event.dart'; import '../../core/libgopeed_boot.dart'; import '../../l10n/l10n.dart'; +import '../../util/log_util.dart'; import '../../util/util.dart'; import 'app_runtime_controller.dart'; @@ -56,13 +56,27 @@ class AppNotificationController extends AsyncNotifier { String? windowsIconPath; try { if (Util.isWindows()) { - final byteData = await rootBundle.load('assets/icon/icon.ico'); - final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/notification_icon.ico'); - await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)); - windowsIconPath = file.path; + // Windows requires a file path for IconUri, so use the bundled PNG + // beside the executable rather than copying it to another directory. + final file = File( + path.join( + path.dirname(Platform.resolvedExecutable), + 'data', + 'flutter_assets', + 'assets', + 'icon', + 'icon_512.png', + ), + ); + if (await file.exists()) { + windowsIconPath = file.absolute.path; + } else { + logger.w('Windows notification icon not found: ${file.path}'); + } } - } catch (_) {} + } catch (error, stackTrace) { + logger.w('prepare Windows notification icon failed', error, stackTrace); + } final windows = WindowsInitializationSettings( appName: 'Gopeed', From bb17c7d48a4dc0fd7586d5398265c7d1da9a4e49 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:50:50 +0800 Subject: [PATCH 6/6] fix(macos): request permission for download notifications (#1498) --- .../app_notification_controller.dart | 20 ++- .../app_notification_controller_test.dart | 139 ++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 ui/flutter/test/app/application/app_notification_controller_test.dart diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index bd09d189..cf8619cb 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -28,13 +28,19 @@ class AppNotificationController extends AsyncNotifier { StreamSubscription? _taskEventSubscription; var _notificationId = 0; + @visibleForTesting + Stream get taskEvents => LibgopeedBoot.instance.taskEvents; + @override Future build() async { final runtime = ref.watch(appRuntimeControllerProvider).value; if (runtime == null || kIsWeb || !Util.isDesktop()) { return const AppNotificationState(); } - await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); + await _initNotifications( + appLocalizationsFor(runtime.downloaderConfig.extra.locale), + requestPermissions: runtime.downloaderConfig.extra.desktopNotification, + ); _listenTaskEvents(); ref.onDispose(() { unawaited(_taskEventSubscription?.cancel()); @@ -42,11 +48,13 @@ class AppNotificationController extends AsyncNotifier { return const AppNotificationState(started: true); } - Future _initNotifications(AppLocalizations locale) async { - const darwin = DarwinInitializationSettings( - requestAlertPermission: false, + Future _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async { + // The runtime is watched, so enabling notifications in settings also + // requests authorization. macOS remembers previously granted/denied access. + final darwin = DarwinInitializationSettings( + requestAlertPermission: requestPermissions, requestBadgePermission: false, - requestSoundPermission: false, + requestSoundPermission: requestPermissions, ); final linux = LinuxInitializationSettings( defaultActionName: locale.open, @@ -92,7 +100,7 @@ class AppNotificationController extends AsyncNotifier { void _listenTaskEvents() { _taskEventSubscription?.cancel(); - _taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async { + _taskEventSubscription = taskEvents.listen((event) async { final runtime = ref.read(appRuntimeControllerProvider).value; if (runtime?.downloaderConfig.extra.desktopNotification == false) return; final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); diff --git a/ui/flutter/test/app/application/app_notification_controller_test.dart b/ui/flutter/test/app/application/app_notification_controller_test.dart new file mode 100644 index 00000000..a74c60d4 --- /dev/null +++ b/ui/flutter/test/app/application/app_notification_controller_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_notification_controller.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/common/task_event.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + final calls = []; + var permissionGranted = true; + late StreamController events; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + MacOSFlutterLocalNotificationsPlugin.registerWith(); + calls.clear(); + permissionGranted = true; + events = StreamController.broadcast(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return permissionGranted; + }); + }); + + tearDown(() { + unawaited(events.close()); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + Future start({required bool enabled}) async { + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)), + appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)), + ], + ); + addTearDown(container.dispose); + await container.read(appRuntimeControllerProvider.future); + await container.read(appNotificationControllerProvider.future); + return container; + } + + Map initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map; + + test('macOS requests alerts and sound when download notifications are enabled at startup', () async { + await start(enabled: true); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + expect(initialization()['requestBadgePermission'], isFalse); + }); + + test('disabled notifications defer authorization until the setting is enabled', () async { + final container = await start(enabled: false); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + }); + + test('denied macOS authorization does not fail notification controller startup', () async { + permissionGranted = false; + final container = await start(enabled: true); + + expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue); + }); + + test('terminal task events notify once after enabling and stop after disabling', () async { + final container = await start(enabled: false); + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip'); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), isEmpty); + + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip')); + await Future.delayed(Duration.zero); + final notifications = calls.where((call) => call.method == 'show').toList(); + expect(notifications, hasLength(2)); + expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), hasLength(2)); + }); +} + +class _TestNotificationController extends AppNotificationController { + _TestNotificationController(this.taskEvents); + + @override + final Stream taskEvents; +} + +class _TestRuntimeController extends AppRuntimeController { + _TestRuntimeController(this.enabled); + + final bool enabled; + + @override + Future build() async => _runtime(enabled); + + void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled)); + + AppRuntimeState _runtime(bool enabled) => AppRuntimeState( + startConfig: StartConfig(), + apiServerState: ApiServerState.fromJson({}), + downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled, + ); +}