From ecfd07ccb0f0ace5f1f63f62963b057ba1965ada Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 6 Sep 2026 22:17:26 +0800 Subject: [PATCH 1/2] fix(ui): refine create task panel behavior --- .../pages/create_task_window_page.dart | 57 ++++++++----------- .../widgets/resolve_file_tree.dart | 4 ++ ui/flutter/lib/l10n/app_ar.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/widgets/app_loading_button.dart | 10 +++- .../create_task_window_page_test.dart | 53 +++++++++++++++++ .../widgets/app_loading_button_test.dart | 5 +- ui/flutter/test/widget_test.dart | 56 ++++++++++++++++++ 27 files changed, 170 insertions(+), 36 deletions(-) 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 889cd482..bce0c31b 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'; @@ -87,6 +87,7 @@ class _CreateTaskWindowPageState extends ConsumerState { String _configuredDownloadDirectory = ''; String _fileDataUri = ''; bool _programmaticUrlChange = false; + String _lastUrlText = ''; @override void initState() { @@ -162,10 +163,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) { @@ -203,9 +209,6 @@ class _CreateTaskWindowPageState extends ConsumerState { } } if (opts != null) { - if (opts.name.isNotEmpty) { - _renameController.text = opts.name; - } if (opts.path.isNotEmpty) { _directoryController.text = opts.path; } @@ -307,6 +310,7 @@ class _CreateTaskWindowPageState extends ConsumerState { SizedBox( height: 120, child: AppTextField( + key: const ValueKey('create-task-url-input'), controller: _urlController, hintText: context.l10n.pasteDownloadLinks, keyboardType: TextInputType.multiline, @@ -689,16 +693,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))), ), ], ), @@ -858,7 +858,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) { @@ -1147,16 +1146,12 @@ class _CreateTaskWindowPageState extends ConsumerState { 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), - ), - ), + AppLoadingButton( + key: const ValueKey('resolve-create-button'), + onPressed: submit, + loading: submitting, + variant: AppLoadingButtonVariant.primary, + child: Text(dialogContext.l10n.createAction, maxLines: 1, softWrap: false), ), ], ); @@ -1318,12 +1313,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; 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/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_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/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/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index c0eeae56..59254019 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 @@ -9,6 +9,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/capability_rpc.dart'; import 'package:gopeed/core/capabilities/gopeed_capability.dart'; @@ -18,6 +20,7 @@ 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; @@ -312,6 +315,56 @@ void main() { expect(options.archivePassword, 'archive-password'); expect(options.deleteAfterExtract, isTrue); expect(submitted?.opts?.asDefaultPath, isTrue); + expect(submitted?.opts?.name, isEmpty); + }); + + testWidgets('resolved task keeps rename empty and uses a single-line intrinsic create action', ( + 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.takeException(), isNull); }); testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( 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 d7fddcbe..f11f9e50 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1860,6 +1860,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(); @@ -2404,6 +2438,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())); @@ -2591,6 +2646,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, From a834ca0d746653f32dd44a939c635538840ac3fc Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 6 Sep 2026 22:26:35 +0800 Subject: [PATCH 2/2] fix(ui): unify text field appearance --- .../pages/create_task_window_page.dart | 27 ++----- .../shared/theme/app_component_themes.dart | 76 ++++++++++--------- .../lib/shared/theme/app_design_tokens.dart | 2 + .../lib/shared/widgets/app_number_input.dart | 2 + .../create_task_window_page_test.dart | 5 +- .../theme/app_component_themes_test.dart | 24 ++++++ ui/flutter/test/widget_test.dart | 4 +- 7 files changed, 83 insertions(+), 57 deletions(-) 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 bce0c31b..e4cbb8bc 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 @@ -356,18 +356,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), @@ -382,10 +376,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); @@ -1654,17 +1644,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/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..e7d1f8ff 100644 --- a/ui/flutter/lib/shared/theme/app_design_tokens.dart +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -21,6 +21,8 @@ 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 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_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 59254019..66d5dfb7 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 @@ -158,7 +158,10 @@ void main() { final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); expect(connectionsInput, findsOneWidget); - expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth); + 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()); 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/widget_test.dart b/ui/flutter/test/widget_test.dart index f11f9e50..5932f4ef 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2336,7 +2336,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)), @@ -4875,7 +4877,7 @@ class _CreateTaskPageHarness extends StatelessWidget { return shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const CreateTaskWindowPage(), + home: const AppComponentThemes(child: CreateTaskWindowPage()), ); } }