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()), ); } }