From 0cc2b690f8d449ea4b49383fd6989c75c0571720 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:02:53 +0800 Subject: [PATCH 01/12] fix: create task panel mobile UI (#1488) --- .github/workflows/scripts/check_l10n.dart | 21 +- .../presentation/pages/extensions_page.dart | 1 + .../pages/create_task_window_page.dart | 86 +- .../widgets/resolve_file_tree.dart | 4 + .../widgets/resolve_task_actions.dart | 50 + ui/flutter/lib/l10n/app_ar.arb | 1 + ui/flutter/lib/l10n/app_az.arb | 1 + ui/flutter/lib/l10n/app_ca.arb | 1 + ui/flutter/lib/l10n/app_de.arb | 1 + ui/flutter/lib/l10n/app_en.arb | 1 + ui/flutter/lib/l10n/app_es.arb | 1 + ui/flutter/lib/l10n/app_fa.arb | 1 + ui/flutter/lib/l10n/app_fr.arb | 1 + ui/flutter/lib/l10n/app_hu.arb | 1 + ui/flutter/lib/l10n/app_id.arb | 1 + ui/flutter/lib/l10n/app_it.arb | 1 + ui/flutter/lib/l10n/app_ja.arb | 1 + ui/flutter/lib/l10n/app_ko.arb | 1 + ui/flutter/lib/l10n/app_pl.arb | 1 + ui/flutter/lib/l10n/app_pt.arb | 1 + ui/flutter/lib/l10n/app_ru.arb | 1 + ui/flutter/lib/l10n/app_ta.arb | 1 + ui/flutter/lib/l10n/app_tr.arb | 1 + ui/flutter/lib/l10n/app_uk.arb | 1 + ui/flutter/lib/l10n/app_vi.arb | 1 + ui/flutter/lib/l10n/app_zh.arb | 1 + ui/flutter/lib/l10n/app_zh_TW.arb | 1 + .../shared/theme/app_component_themes.dart | 76 +- .../lib/shared/theme/app_design_tokens.dart | 4 + .../shared/widgets/app_loading_button.dart | 10 +- .../lib/shared/widgets/app_number_input.dart | 2 + .../create_task_window_page_test.dart | 1248 +++++++---------- .../resolve_task_actions_test.dart | 77 + .../theme/app_component_themes_test.dart | 24 + .../widgets/app_loading_button_test.dart | 5 +- ui/flutter/test/widget_test.dart | 66 +- 36 files changed, 869 insertions(+), 827 deletions(-) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart create mode 100644 ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart diff --git a/.github/workflows/scripts/check_l10n.dart b/.github/workflows/scripts/check_l10n.dart index a542a51f..712f5419 100644 --- a/.github/workflows/scripts/check_l10n.dart +++ b/.github/workflows/scripts/check_l10n.dart @@ -6,15 +6,19 @@ // What it checks: // - Every ARB file uses the repository's canonical two-space JSON formatting. // - Every app_.arb file declares the matching @@locale value. -// - Message values are not empty. -// - Every locale has exactly the same message keys as app_en.arb. -// - Every translation preserves the placeholder variables used by app_en.arb. +// - Message values are not empty (warning only). +// - Every locale has the same message keys as app_en.arb (warning only). +// - Every translation preserves the placeholder variables used by app_en.arb +// (warning only). // // Notes: // - This script does not judge translation quality. // - It does not generate localization code or fully parse ICU MessageFormat. // `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax // and generate app_localizations*.dart. +// - The script exits with a failure only for malformed JSON, invalid ARB +// formatting, or a mismatched @@locale value. Translation completeness is +// reported as warnings; generation and analysis remain the compile-time gate. import 'dart:convert'; import 'dart:io'; @@ -49,27 +53,24 @@ void main() { keys.where((key) => (messages[key] as String).trim().isEmpty).toList() ..sort(); if (empty.isNotEmpty) { - stderr.writeln('${file.path}: empty messages $empty'); - failed = true; + _warning('${file.path}: empty messages $empty'); } if (file.path == templateFile.path) continue; final missing = templateKeys.difference(keys); final extra = keys.difference(templateKeys); if (missing.isNotEmpty || extra.isNotEmpty) { - stderr.writeln( + _warning( '${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}', ); - failed = true; } for (final key in templateKeys.intersection(keys)) { final expected = _placeholders(template[key] as String); final actual = _placeholders(messages[key] as String); if (!_sameSet(expected, actual)) { - stderr.writeln( + _warning( '${file.path}: $key placeholders $actual do not match $expected', ); - failed = true; } } } @@ -102,3 +103,5 @@ Set _placeholders(String message) => RegExp( /// Compares sets without depending on iteration order. bool _sameSet(Set left, Set right) => left.length == right.length && left.containsAll(right); + +void _warning(String message) => stderr.writeln('warning: $message'); diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index 714acf69..5aa57e6a 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -633,6 +633,7 @@ class _Toolbar extends StatelessWidget { final search = AppTextField( key: const ValueKey('extension-search-input'), controller: searchController, + padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null, placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)), features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))], onSubmitted: onSearch, diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 89539624..05d37d9b 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -29,9 +29,9 @@ import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_http_headers_editor.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_number_input.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; -import '../../../../shared/widgets/app_primary_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -39,6 +39,7 @@ import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; import '../widgets/resolve_file_tree.dart'; +import '../widgets/resolve_task_actions.dart'; class CreateTaskWindowPage extends ConsumerStatefulWidget { const CreateTaskWindowPage({super.key, this.windowController, this.initialTask}); @@ -87,6 +88,7 @@ class _CreateTaskWindowPageState extends ConsumerState { String _configuredDownloadDirectory = ''; String _fileDataUri = ''; bool _programmaticUrlChange = false; + String _lastUrlText = ''; @override void initState() { @@ -172,10 +174,15 @@ class _CreateTaskWindowPageState extends ConsumerState { } void _handleUrlChanged() { - if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { - _fileDataUri = ''; + final urlText = _urlController.text; + if (urlText != _lastUrlText) { + _lastUrlText = urlText; + _renameController.clear(); + if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { + _fileDataUri = ''; + } } - _recognizeMagnetUri(_urlController.text.trim()); + _recognizeMagnetUri(urlText.trim()); } void _applyInitialTask(CreateTask? task) { @@ -220,9 +227,6 @@ class _CreateTaskWindowPageState extends ConsumerState { } } if (opts != null) { - if (opts.name.isNotEmpty) { - _renameController.text = opts.name; - } if (opts.path.isNotEmpty) { _directoryController.text = opts.path; } @@ -370,18 +374,12 @@ class _CreateTaskWindowPageState extends ConsumerState { const SizedBox(height: 16), _FormRow( label: context.l10n.connections, - child: Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: AppDesignTokens.settingsNumberControlWidth, - child: AppNumberInput( - fieldKey: const ValueKey('create-task-connections-input'), - controller: _connectionsController, - min: 1, - max: 256, - hintText: context.l10n.enterCount, - ), - ), + child: AppNumberInput( + fieldKey: const ValueKey('create-task-connections-input'), + controller: _connectionsController, + min: 1, + max: 256, + hintText: context.l10n.enterCount, ), ), const SizedBox(height: 16), @@ -396,10 +394,6 @@ class _CreateTaskWindowPageState extends ConsumerState { pickerKey: const ValueKey('create-task-directory-picker'), controller: _directoryController, hintText: context.l10n.chooseDownloadDirectory, - filled: true, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), onChanged: (_) { if (_asDefaultPath) { setState(() => _asDefaultPath = false); @@ -707,16 +701,12 @@ class _CreateTaskWindowPageState extends ConsumerState { child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))), ), const SizedBox(width: 12), - AppPrimaryButton( - onPressed: _creating ? null : _confirm, - child: SizedBox( - width: 68, - child: Center( - child: _creating - ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) - : Text(context.l10n.confirm), - ), - ), + AppLoadingButton( + key: const ValueKey('create-task-confirm-button'), + onPressed: _confirm, + loading: _creating, + variant: AppLoadingButtonVariant.primary, + child: SizedBox(width: 68, child: Center(child: Text(context.l10n.confirm))), ), ], ), @@ -884,7 +874,6 @@ class _CreateTaskWindowPageState extends ConsumerState { final options = _buildOptions(); final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options)); if (!mounted) return; - _syncResolvedName(result); final created = await _showResolveDialog(request, result); if (!created) { if (mounted) { @@ -1169,19 +1158,11 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), actions: [ - SecondaryButton( - onPressed: submitting ? null : () => closeOverlay(dialogContext, false), - child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))), - ), - AppPrimaryButton( - onPressed: submitting ? null : submit, - child: SizedBox( - width: 68, - child: Center( - child: submitting - ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) - : Text(dialogContext.l10n.create), - ), + flutter.Flexible( + child: ResolveTaskActions( + submitting: submitting, + onCancel: () => closeOverlay(dialogContext, false), + onCreate: submit, ), ), ], @@ -1344,12 +1325,6 @@ class _CreateTaskWindowPageState extends ConsumerState { filterController.dispose(); } - void _syncResolvedName(ResolveResult result) { - if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) { - _renameController.text = result.res.name; - } - } - void _setUrlText(String value, {String fileDataUri = ''}) { _programmaticUrlChange = true; _urlController.text = value; @@ -1691,17 +1666,12 @@ class _WindowTextField extends StatelessWidget { @override Widget build(BuildContext context) { - final palette = AppPalette.of(context); return AppTextField( controller: controller, hintText: hintText, keyboardType: keyboardType, inputFormatters: inputFormatters, obscureText: obscureText, - filled: true, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart index 60e88399..d5f8af8e 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart @@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State { child: FileTreeView( items: _treeItems, keyPrefix: 'resolve-tree', + rowHeight: _resolveTreeRowHeight, + contentTextStyle: _resolveFileNameStyle, headerLeading: ExcludeFocus( child: Checkbox( state: allSelected @@ -336,3 +338,5 @@ extension on _FileTypeFilter { const _videoExts = {'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'}; const _audioExts = {'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'}; const _imageExts = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'}; +const _resolveTreeRowHeight = 36.0; +const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25); diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart new file mode 100644 index 00000000..e9b6dc37 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart @@ -0,0 +1,50 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/widgets.dart' as flutter; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; + +class ResolveTaskActions extends StatelessWidget { + const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate}); + + final bool submitting; + final VoidCallback onCancel; + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + // Size both actions from the wider translated label, including while the + // create button replaces its label with a loader. + return IntrinsicWidth( + child: flutter.Row( + mainAxisSize: MainAxisSize.min, + children: [ + flutter.Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth), + child: SecondaryButton( + key: const ValueKey('resolve-cancel-button'), + onPressed: submitting ? null : onCancel, + child: Text(context.l10n.cancel, maxLines: 1, softWrap: false), + ), + ), + ), + const SizedBox(width: AppDesignTokens.space8), + flutter.Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth), + child: AppLoadingButton( + key: const ValueKey('resolve-create-button'), + onPressed: onCreate, + loading: submitting, + variant: AppLoadingButtonVariant.primary, + child: Text(context.l10n.createAction, maxLines: 1, softWrap: false), + ), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 3569cc64..830d6eb1 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -18,6 +18,7 @@ "exit": "خروج", "pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج", "create": "إنشاء مهمة", + "createAction": "إنشاء", "directDownload": "تنزيل مباشر", "advancedOptions": "خيارات متقدمة", "followSettings": "اتباع الإعدادات", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 70eafe0d..d9d5bcfd 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -18,6 +18,7 @@ "exit": "Çıxış", "pressBackAgainToExit": "Çıxmaq üçün yenidən geri düyməsinə basın", "create": "Tapşırıq yarat", + "createAction": "Yarat", "directDownload": "Birbaşa Endirmə", "advancedOptions": "Əlavə Seçimlər", "followSettings": "Parametrlərə uyğun", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 7743b429..1daea2e6 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -18,6 +18,7 @@ "exit": "Surt", "pressBackAgainToExit": "Torna a prémer enrere per sortir", "create": "Crea una tasca", + "createAction": "Crea", "directDownload": "Baixada directa", "advancedOptions": "Opcions avançades", "followSettings": "Segueix la configuració", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 2a347170..fdd924ff 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -18,6 +18,7 @@ "exit": "Beenden", "pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden", "create": "Aufgabe Erstellen", + "createAction": "Erstellen", "directDownload": "Direkter Download", "advancedOptions": "Erweiterte Einstellungen", "followSettings": "Einstellungen Folgen", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 578e762d..1a0fcfa2 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -18,6 +18,7 @@ "exit": "Exit", "pressBackAgainToExit": "Press back again to exit", "create": "Create Task", + "createAction": "Create", "directDownload": "Direct Download", "advancedOptions": "Advanced Options", "followSettings": "Follow Settings", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 6aef1468..c93209d1 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -18,6 +18,7 @@ "exit": "Salir", "pressBackAgainToExit": "Pulsa Atrás de nuevo para salir", "create": "Crear Tarea", + "createAction": "Crear", "directDownload": "Descarga Directa", "advancedOptions": "Opciones Avanzadas", "followSettings": "Siga las opciones", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 7398b5c3..3943a61d 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -18,6 +18,7 @@ "exit": "خروج", "pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید", "create": "ایجاد کار", + "createAction": "ایجاد", "directDownload": "دانلود مستقیم", "advancedOptions": "گزینه های پیشرفته", "followSettings": "تنظیمات را دنبال کنید", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 43404fdc..4af83656 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -18,6 +18,7 @@ "exit": "Quitter", "pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter", "create": "Créer une tâche", + "createAction": "Créer", "directDownload": "Téléchargement direct", "advancedOptions": "Options avancées", "followSettings": "Suivre les paramètres", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index cd5d2b2d..9c2fd4c8 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -18,6 +18,7 @@ "exit": "Kilépés", "pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez", "create": "Feladat létrehozása", + "createAction": "Létrehozás", "directDownload": "Közvetlen letöltés", "advancedOptions": "Haladó beállítások", "followSettings": "Beállítások követése", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 1f87a970..857ec774 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -18,6 +18,7 @@ "exit": "Keluar", "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", "create": "Membuat Tugas", + "createAction": "Buat", "directDownload": "Unduhan Langsung", "advancedOptions": "Pengaturan Lanjutan", "followSettings": "Mengikuti Pengaturan", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 7d298adb..01263794 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -18,6 +18,7 @@ "exit": "Uscita", "pressBackAgainToExit": "Premi di nuovo Indietro per uscire", "create": "Crea attività", + "createAction": "Crea", "directDownload": "Scaricamento diretto", "advancedOptions": "Opzioni avanzate", "followSettings": "Seguire le impostazioni", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 0bdbc743..d816b478 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -18,6 +18,7 @@ "exit": "終了", "pressBackAgainToExit": "もう一度戻るボタンを押すと終了します", "create": "タスクを作成", + "createAction": "作成", "directDownload": "直接ダウンロード", "advancedOptions": "詳細オプション", "followSettings": "設定に従う", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index db8caf2d..a4d3f9da 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -18,6 +18,7 @@ "exit": "종료", "pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요", "create": "작업 생성", + "createAction": "생성", "directDownload": "바로 다운로드", "advancedOptions": "고급 옵션", "followSettings": "설정값 사용", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index c255d506..6aea60ce 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -18,6 +18,7 @@ "exit": "Wyjście", "pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść", "create": "Utwórz zadanie", + "createAction": "Utwórz", "directDownload": "Pobieranie bezpośrednie", "advancedOptions": "Zaawansowane", "followSettings": "Podążaj za ustawieniami", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 8447f830..39b3a81a 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -18,6 +18,7 @@ "exit": "Sair", "pressBackAgainToExit": "Prima novamente Voltar para sair", "create": "Criar Tarefa", + "createAction": "Criar", "directDownload": "Download Direto", "advancedOptions": "Opções Avançadas", "followSettings": "Seguir as Configurações", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 1cc5fe24..782a5c9e 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -18,6 +18,7 @@ "exit": "Выход", "pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти", "create": "Создать задачу", + "createAction": "Создать", "directDownload": "Прямая загрузка", "advancedOptions": "Расширенные опции", "followSettings": "По умолчанию", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 82870d83..00d06c0c 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -18,6 +18,7 @@ "exit": "வெளியேறு", "pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்", "create": "பணியை உருவாக்கு", + "createAction": "உருவாக்கு", "directDownload": "நேரடி பதிவிறக்கம்", "advancedOptions": "மேம்பட்ட விருப்பங்கள்", "followSettings": "அமைப்புகளைப் பின்தொடரவும்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index ca979cae..a753215d 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -18,6 +18,7 @@ "exit": "Çıkış", "pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın", "create": "İşlem Oluştur", + "createAction": "Oluştur", "directDownload": "Direkt İndir", "advancedOptions": "Gelişmiş Seçenekler", "followSettings": "Ayarları Takip Et", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index ceae4052..82c6cc34 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -18,6 +18,7 @@ "exit": "Вихід", "pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти", "create": "Створити завдання", + "createAction": "Створити", "directDownload": "Пряме завантаження", "advancedOptions": "Додаткові параметри", "followSettings": "За замовчуванням", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 6ed35bd1..ccd43cb7 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -18,6 +18,7 @@ "exit": "Thoát", "pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát", "create": "Tạo nhiệm vụ", + "createAction": "Tạo", "directDownload": "Tải trực tiếp", "advancedOptions": "Tùy chọn nâng cao", "followSettings": "Theo thiết lập", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 96e8a184..ddc6e16d 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -18,6 +18,7 @@ "exit": "退出", "pressBackAgainToExit": "再按一次返回键退出应用", "create": "创建任务", + "createAction": "创建", "directDownload": "直接下载", "advancedOptions": "高级选项", "followSettings": "跟随设置", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index c75a84c7..db5bb6a2 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -18,6 +18,7 @@ "exit": "退出", "pressBackAgainToExit": "再按一次返回鍵退出應用程式", "create": "建立任務", + "createAction": "建立", "directDownload": "直接下載", "advancedOptions": "進階選項", "followSettings": "跟隨設定", diff --git a/ui/flutter/lib/shared/theme/app_component_themes.dart b/ui/flutter/lib/shared/theme/app_component_themes.dart index 5b0915a0..bc6611db 100644 --- a/ui/flutter/lib/shared/theme/app_component_themes.dart +++ b/ui/flutter/lib/shared/theme/app_component_themes.dart @@ -18,42 +18,50 @@ class AppComponentThemes extends StatelessWidget { align: 1, border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1), ), - child: shad.ComponentTheme( - data: shad.SecondaryButtonTheme( - decoration: (context, states, value) { - final decoration = switch (value) { - final BoxDecoration decoration => decoration, - _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), - }; - if (states.contains(WidgetState.disabled)) { - return decoration.copyWith(color: palette.surfaceSoft); - } - if (theme.brightness == Brightness.light) { - final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); - final hoverBackground = Color.alphaBlend( - palette.textPrimary.withValues(alpha: 0.13), - palette.surfaceSoft, - ); - return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); - } - return value; - }, - textStyle: (context, states, value) => states.contains(WidgetState.disabled) - ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) - : value, - iconTheme: (context, states, value) => states.contains(WidgetState.disabled) - ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) - : value, + child: shad.ComponentTheme( + data: shad.TextFieldTheme( + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + padding: AppDesignTokens.textFieldPadding, ), - child: shad.ComponentTheme( - data: shad.CheckboxTheme( - size: AppDesignTokens.checkboxSize, - gap: AppDesignTokens.checkboxLabelGap, - borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + child: shad.ComponentTheme( + data: shad.SecondaryButtonTheme( + decoration: (context, states, value) { + final decoration = switch (value) { + final BoxDecoration decoration => decoration, + _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), + }; + if (states.contains(WidgetState.disabled)) { + return decoration.copyWith(color: palette.surfaceSoft); + } + if (theme.brightness == Brightness.light) { + final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); + final hoverBackground = Color.alphaBlend( + palette.textPrimary.withValues(alpha: 0.13), + palette.surfaceSoft, + ); + return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); + } + return value; + }, + textStyle: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, + iconTheme: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, ), - child: shad.ComponentTheme( - data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), - child: child, + child: shad.ComponentTheme( + data: shad.CheckboxTheme( + size: AppDesignTokens.checkboxSize, + gap: AppDesignTokens.checkboxLabelGap, + borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + ), + child: shad.ComponentTheme( + data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), + child: child, + ), ), ), ), diff --git a/ui/flutter/lib/shared/theme/app_design_tokens.dart b/ui/flutter/lib/shared/theme/app_design_tokens.dart index 7847316d..615fb139 100644 --- a/ui/flutter/lib/shared/theme/app_design_tokens.dart +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -10,6 +10,7 @@ class AppDesignTokens { static const double contentHeaderHeight = 48; static const double taskRowHeight = 72; static const double controlRadius = 4; + static const double dialogActionMinWidth = 100; static const double checkboxSize = 18; static const double checkboxLabelGap = 10; static const double taskDetailsDrawerMinWidth = 440; @@ -21,6 +22,9 @@ class AppDesignTokens { static const double settingsFormControlWidth = 420; static const double settingsNumberControlWidth = 168; static const double settingsItemMinLabelWidth = 160; + static const EdgeInsets textFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 11); + static const EdgeInsets compactTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 6); + static const EdgeInsets numberTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 10); static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32); static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16); diff --git a/ui/flutter/lib/shared/widgets/app_loading_button.dart b/ui/flutter/lib/shared/widgets/app_loading_button.dart index b203ed33..f886204a 100644 --- a/ui/flutter/lib/shared/widgets/app_loading_button.dart +++ b/ui/flutter/lib/shared/widgets/app_loading_button.dart @@ -30,7 +30,15 @@ class AppLoadingButton extends StatelessWidget { final palette = AppPalette.of(context); final replaceChild = loading && icon == null; final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon; - final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child; + final effectiveChild = replaceChild + ? Stack( + alignment: Alignment.center, + children: [ + ExcludeSemantics(child: Opacity(opacity: 0, child: child)), + Positioned.fill(child: _LoadingIcon(variant: variant)), + ], + ) + : child; final content = leading == null ? effectiveChild : Row( diff --git a/ui/flutter/lib/shared/widgets/app_number_input.dart b/ui/flutter/lib/shared/widgets/app_number_input.dart index cda9545c..70d4bb52 100644 --- a/ui/flutter/lib/shared/widgets/app_number_input.dart +++ b/ui/flutter/lib/shared/widgets/app_number_input.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../theme/app_design_tokens.dart'; import 'app_text_field.dart'; /// A bounded numeric text field with the app's standard spinner controls. @@ -91,6 +92,7 @@ class _AppNumberInputState extends State { : TextInputType.number, inputFormatters: formatters, enabled: widget.enabled, + padding: AppDesignTokens.numberTextFieldPadding, features: [ shad.InputFeature.spinner( step: widget.step, diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index 5ae33c45..8b05f599 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -2,8 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/widgets.dart' as flutter show Row; -import 'package:flutter/foundation.dart' - show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart' show Icons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,6 +11,8 @@ import 'package:gopeed/api/model/create_task.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resolve_result.dart'; +import 'package:gopeed/api/model/resource.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/core/capabilities/capability_rpc.dart'; @@ -23,188 +24,148 @@ import 'package:gopeed/shared/services/download_directory_picker.dart'; import 'package:gopeed/shared/theme/app_component_themes.dart'; import 'package:gopeed/shared/theme/app_design_tokens.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { for (final navigationFails in [false, true]) { - testWidgets( - 'child creation selects downloads before closing (navigation fails: $navigationFails)', - (tester) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final events = []; - const channel = MethodChannel('window_manager'); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler(channel, (call) async { - events.add(call.method); - return null; + testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final events = []; + const channel = MethodChannel('window_manager'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + events.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind(GopeedMethods.createTask, (_) { + events.add('created'); + return 'task-id'; + }) + ..bind(NavigationMethods.showDownloadingTasks, (_) { + events.add('downloads'); + if (navigationFails) throw StateError('Navigation unavailable'); + return const RpcUnit(); }); - addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); - final config = DownloaderConfig(downloadDir: '/downloads') - ..extra.defaultDirectDownload = true; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => config) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) - ..bind(GopeedMethods.createTask, (_) { - events.add('created'); - return 'task-id'; - }) - ..bind(NavigationMethods.showDownloadingTasks, (_) { - events.add('downloads'); - if (navigationFails) throw StateError('Navigation unavailable'); - return const RpcUnit(); - }); - await tester.pumpWidget( - ProviderScope( - overrides: [ - appCapabilitiesProvider.overrideWithValue( - AppCapabilities(LocalCapabilityInvoker(registry)), - ), - ], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: AppComponentThemes( - child: CreateTaskWindowPage( - windowController: WindowController.fromWindowId('child'), - initialTask: CreateTask( - req: Request(url: 'https://example.com/file.apk'), - ), - ), + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: CreateTaskWindowPage( + windowController: WindowController.fromWindowId('child'), + initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')), ), ), ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Confirm')); - // The mocked native close deliberately leaves the widget mounted. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - expect(events, ['created', 'downloads', 'close']); - await tester.pumpWidget(const SizedBox()); - }, - ); + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirm')); + // The mocked native close deliberately leaves the widget mounted. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(events, ['created', 'downloads', 'close']); + await tester.pumpWidget(const SizedBox()); + }); } - testWidgets( - 'repeated external tasks update the open create route and replace request metadata', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - tester.view.physicalSize = const Size(700, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - CreateTask? submitted; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind( - GopeedMethods.getConfig, - (_) => DownloaderConfig(downloadDir: '/downloads'), - ) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-id'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final container = ProviderContainer( - overrides: [ - appCapabilitiesProvider.overrideWithValue( - AppCapabilities(LocalCapabilityInvoker(registry)), - ), - ], - ); - addTearDown(container.dispose); - final pending = container.read(pendingCreateTaskProvider.notifier); - pending.set( - CreateTask( - req: Request( - url: 'https://example.com/first.zip', - rawUrl: 'https://example.com/first', - labels: {'source': 'first'}, - extra: ReqExtraHttp( - method: 'POST', - body: 'old-body', - header: {'Cookie': 'old-cookie'}, - ).toJson(), - proxy: RequestProxy( - mode: RequestProxyMode.custom, - host: 'localhost:8080', - ), - skipVerifyCert: true, - ), + CreateTask? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + final pending = container.read(pendingCreateTaskProvider.notifier); + pending.set( + CreateTask( + req: Request( + url: 'https://example.com/first.zip', + rawUrl: 'https://example.com/first', + labels: {'source': 'first'}, + extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(), + proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'), + skipVerifyCert: true, ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute( - path: '/create', - builder: (_, _) => const CreateTaskWindowPage(), - ), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - routerConfig: router, - builder: (_, child) => AppComponentThemes(child: child!), - ), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), ), - ); + ), + ); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip'); + expect(container.read(pendingCreateTaskProvider), isNull); + final pageState = tester.state(find.byType(CreateTaskWindowPage)); + + for (final url in [ + 'https://example.com/second.zip', + 'https://example.com/third.zip', + 'https://example.com/third.zip', + ]) { + pending.set(CreateTask(req: Request(url: url))); + router.go('/create'); await tester.pumpAndSettle(); - expect( - _fieldText(tester, 'create-task-url-input'), - 'https://example.com/first.zip', - ); + expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState)); + expect(_fieldText(tester, 'create-task-url-input'), url); expect(container.read(pendingCreateTaskProvider), isNull); - final pageState = tester.state(find.byType(CreateTaskWindowPage)); - - for (final url in [ - 'https://example.com/second.zip', - 'https://example.com/third.zip', - 'https://example.com/third.zip', - ]) { - pending.set(CreateTask(req: Request(url: url))); - router.go('/create'); - await tester.pumpAndSettle(); - expect( - tester.state(find.byType(CreateTaskWindowPage)), - same(pageState), - ); - expect(_fieldText(tester, 'create-task-url-input'), url); - expect(container.read(pendingCreateTaskProvider), isNull); - } - await tester.tap( - find.byKey(const ValueKey('create-task-direct-download-toggle')), - ); - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - expect(submitted?.req?.url, 'https://example.com/third.zip'); - expect(submitted?.req?.rawUrl, isNull); - expect(submitted?.req?.labels, isNull); - expect(submitted?.req?.extra, isNull); - expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); - expect(submitted?.req?.skipVerifyCert, isFalse); - expect(tester.takeException(), isNull); - } finally { - debugDefaultTargetPlatformOverride = null; } - }, - ); + await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle'))); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted?.req?.url, 'https://example.com/third.zip'); + expect(submitted?.req?.rawUrl, isNull); + expect(submitted?.req?.labels, isNull); + expect(submitted?.req?.extra, isNull); + expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); + expect(submitted?.req?.skipVerifyCert, isFalse); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); - testWidgets('mobile create task page starts below the system status bar', ( - WidgetTester tester, - ) async { + testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); tester.view.devicePixelRatio = 1; @@ -214,10 +175,7 @@ void main() { addTearDown(tester.view.resetPadding); final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind( - GopeedMethods.getConfig, - (_) => DownloaderConfig(downloadDir: '/downloads'), - ); + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); try { @@ -233,573 +191,421 @@ void main() { ); await tester.pumpAndSettle(); - final contentPadding = tester.widget( - find.byKey(const ValueKey('create-task-safe-content')), - ); + final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); expect((contentPadding.padding as EdgeInsets).top, 32); } finally { debugDefaultTargetPlatformOverride = null; } }); - testWidgets( - 'browser extension request parameters populate the form and survive submission', - (WidgetTester tester) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; - final androidLocations = { - 'application': 'D:/Downloads', - 'downloads': 'G:/Manually selected', - }; - DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => - androidLocations; - DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; - addTearDown(() { - DownloadDirectoryPicker.debugPlatformOverride = null; - DownloadDirectoryPicker.debugAndroidLocationsLoader = null; - DownloadDirectoryPicker.debugDownloadsPreparer = null; + testWidgets('browser extension request parameters populate the form and survive submission', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + final androidLocations = {'application': 'D:/Downloads', 'downloads': 'G:/Manually selected'}; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => androidLocations; + DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; + addTearDown(() { + DownloadDirectoryPicker.debugPlatformOverride = null; + DownloadDirectoryPicker.debugAndroidLocationsLoader = null; + DownloadDirectoryPicker.debugDownloadsPreparer = null; + }); + + CreateTask? submitted; + final config = DownloaderConfig(downloadDir: 'C:/Downloads') + ..extra.defaultDirectDownload = true + ..extra.downloadCategories = [ + DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), + DownloadCategory(name: 'Archives', path: 'F:/Archives'), + DownloadCategory(name: 'Long video category', path: 'G:/Videos'), + DownloadCategory(name: 'Long document category', path: 'H:/Documents'), + ] + ..protocolConfig.http.connections = 8; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-task'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + final initialTask = CreateTask( + req: Request( + rawUrl: 'https://page.example/download', + url: 'https://cdn.example/archive.zip', + extra: ReqExtraHttp( + method: 'POST', + header: const { + 'User-Agent': 'Browser UA', + 'Cookie': 'session=abc', + 'Referer': 'https://page.example/', + 'Authorization': 'Bearer token', + }, + body: 'request-body', + ).toJson(), + labels: const {'source': 'browser-extension'}, + proxy: RequestProxy( + mode: RequestProxyMode.custom, + scheme: 'socks5', + host: '127.0.0.1:7890', + usr: 'proxy-user', + pwd: 'proxy-password', + ), + skipVerifyCert: true, + ), + opts: Options( + name: 'archive.zip', + path: 'D:/Downloads', + extra: OptsExtraHttp( + connections: 12, + autoTorrent: true, + deleteTorrentAfterDownload: true, + autoExtract: true, + archivePassword: 'archive-password', + deleteAfterExtract: true, + ).toJson(), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/create', + builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); + + final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); + expect(connectionsInput, findsOneWidget); + expect( + tester.getSize(connectionsInput).width, + tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width, + ); + final connectionsField = tester.widget(connectionsInput); + expect(connectionsField.controller!.text, '12'); + expect(connectionsField.features.single, isA()); + + final advancedSection = find.byKey(const ValueKey('create-task-advanced-section')); + expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showSecond); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showFirst); + + expect(_fieldText(tester, 'create-task-http-header-name-0'), 'User-Agent'); + expect(_fieldText(tester, 'create-task-http-header-value-0'), 'Browser UA'); + expect(_fieldText(tester, 'create-task-http-header-name-3'), 'Authorization'); + expect(_fieldText(tester, 'create-task-http-header-value-3'), 'Bearer token'); + expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); + expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); + expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); + expect(_fieldText(tester, 'create-task-proxy-password'), 'proxy-password'); + expect(_fieldText(tester, 'create-task-archive-password'), 'archive-password'); + expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-auto-torrent')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-delete-torrent')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-delete-after-extract')), findsOneWidget); + + expect(find.text('Archives'), findsNWidgets(2)); + expect(find.text('Quick folders'), findsNothing); + expect(find.byKey(const ValueKey('create-task-category-0')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-1')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-2')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-3')), findsOneWidget); + final shortcutRow = find.byKey(const ValueKey('create-task-directory-shortcuts')); + expect(tester.getSize(shortcutRow).height, 28); + final rememberDirectory = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); + final directDownloadCheckbox = find.byKey(const ValueKey('create-task-direct-download-checkbox')); + final rememberDirectoryRow = find.byKey(const ValueKey('create-task-remember-download-directory')); + final rememberDirectoryLabel = find.byKey(const ValueKey('create-task-remember-download-directory-label')); + final directDownloadLabel = find.descendant( + of: find.byKey(const ValueKey('create-task-direct-download-toggle')), + matching: find.text('Skip resolving and create tasks immediately'), + ); + final directoryComponent = find.byKey(const ValueKey('create-task-directory-component')); + final directoryOptionsRow = find.byKey(const ValueKey('create-task-directory-options-row')); + final directoryOptionsLayout = find.byKey(const ValueKey('create-task-directory-options-layout')); + final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker')); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect(find.text('Save as default download directory'), findsOneWidget); + final rememberStyle = tester.widget(rememberDirectoryLabel).style!; + final directStyle = tester.widget(directDownloadLabel).style!; + expect(rememberStyle.color, directStyle.color); + expect(rememberStyle.fontSize, directStyle.fontSize); + expect(rememberStyle.fontWeight, directStyle.fontWeight); + expect(tester.widget(rememberDirectory).size, isNull); + expect(tester.getSize(rememberDirectory), const Size.square(AppDesignTokens.checkboxSize)); + expect(tester.getCenter(rememberDirectory).dx, closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5)); + final rememberDirectoryText = tester.widget(rememberDirectoryLabel); + final directDownloadText = tester.widget(directDownloadLabel); + expect(rememberDirectoryText.style, directDownloadText.style); + expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); + expect(rememberDirectoryText.overflow, directDownloadText.overflow); + expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); + expect(tester.widget(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween); + expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5)); + expect(tester.getTopRight(directoryOptionsRow).dx, closeTo(tester.getTopRight(directoryComponent).dx, 0.5)); + expect( + tester.getTopRight(find.byKey(const ValueKey('create-task-category-3'))).dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + + tester.view.physicalSize = const Size(520, 720); + await tester.pumpAndSettle(); + final shortcutContent = find.byKey(const ValueKey('create-task-directory-shortcuts-content')); + expect(tester.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy)); + expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5)); + expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width)); + final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable)); + expect(tester.state(shortcutScrollable).position.maxScrollExtent, greaterThan(0)); + + tester.view.physicalSize = const Size(719, 720); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))), + isA(), + ); + + tester.view.physicalSize = const Size(720, 720); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))).mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + + tester.view.physicalSize = const Size(1024, 720); + await tester.pumpAndSettle(); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); + + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); + await tester.pump(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect( + find.descendant( + of: find.byKey(const ValueKey('create-task-category-3')), + matching: find.byIcon(Icons.folder_outlined), + ), + findsOneWidget, + ); + + androidLocations['downloads'] = 'C:/Downloads'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + + androidLocations['downloads'] = 'G:/Manually selected'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); + + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + final request = submitted?.req; + expect(request, isNotNull); + expect(request?.rawUrl, initialTask.req?.rawUrl); + expect(request?.labels, initialTask.req?.labels); + expect(request?.proxy?.mode, RequestProxyMode.custom); + expect(request?.proxy?.scheme, 'socks5'); + expect(request?.proxy?.host, '127.0.0.1:7890'); + expect(request?.proxy?.usr, 'proxy-user'); + expect(request?.proxy?.pwd, 'proxy-password'); + expect(request?.skipVerifyCert, isTrue); + expect(ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), initialTask.req?.extra); + expect(submitted?.opts?.path, 'G:/Manually selected'); + final options = OptsExtraHttp.fromJson(submitted?.opts?.extra! as Map); + expect(options.connections, 12); + expect(options.autoTorrent, isTrue); + expect(options.deleteTorrentAfterDownload, isTrue); + expect(options.autoExtract, isTrue); + expect(options.archivePassword, 'archive-password'); + expect(options.deleteAfterExtract, isTrue); + expect(submitted?.opts?.asDefaultPath, isTrue); + }); + + testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async { + tester.view.physicalSize = const Size(700, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved-id', + res: Resource( + name: 'server-generated-name', + files: [FileInfo(name: 'video.mp4', size: 1024)], + ), + ), + ) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(_fieldText(tester, 'create-task-rename-input'), isEmpty); + final createButton = tester.widget(find.byKey(const ValueKey('resolve-create-button'))); + expect(createButton.child, isA()); + final createText = createButton.child as Text; + expect(createText.data, 'Create'); + expect(createText.maxLines, 1); + expect(createText.softWrap, isFalse); + expect( + tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))), + tester.getSize(find.byKey(const ValueKey('resolve-create-button'))), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final histories = [ + 'https://example.com/alpha.zip', + 'https://example.com/beta.iso', + 'magnet:?xt=urn:btih:gamma', + ]; + String? removedHistory; + var cleared = false; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(StorageMethods.getCreateHistory, (_) => List.unmodifiable(histories)) + ..bind(StorageMethods.removeCreateHistory, (value) { + removedHistory = value; + histories.remove(value); + return const RpcUnit(); + }) + ..bind(StorageMethods.clearCreateHistory, (_) { + cleared = true; + histories.clear(); + return const RpcUnit(); }); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - CreateTask? submitted; - final config = DownloaderConfig(downloadDir: 'C:/Downloads') - ..extra.defaultDirectDownload = true - ..extra.downloadCategories = [ - DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), - DownloadCategory(name: 'Archives', path: 'F:/Archives'), - DownloadCategory(name: 'Long video category', path: 'G:/Videos'), - DownloadCategory( - name: 'Long document category', - path: 'H:/Documents', - ), - ] - ..protocolConfig.http.connections = 8; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => config) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-task'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - - final initialTask = CreateTask( - req: Request( - rawUrl: 'https://page.example/download', - url: 'https://cdn.example/archive.zip', - extra: ReqExtraHttp( - method: 'POST', - header: const { - 'User-Agent': 'Browser UA', - 'Cookie': 'session=abc', - 'Referer': 'https://page.example/', - 'Authorization': 'Bearer token', - }, - body: 'request-body', - ).toJson(), - labels: const {'source': 'browser-extension'}, - proxy: RequestProxy( - mode: RequestProxyMode.custom, - scheme: 'socks5', - host: '127.0.0.1:7890', - usr: 'proxy-user', - pwd: 'proxy-password', - ), - skipVerifyCert: true, + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), ), - opts: Options( - name: 'archive.zip', - path: 'D:/Downloads', - extra: OptsExtraHttp( - connections: 12, - autoTorrent: true, - deleteTorrentAfterDownload: true, - autoExtract: true, - archivePassword: 'archive-password', - deleteAfterExtract: true, - ).toJson(), - ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute( - path: '/create', - builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), - ), - ], - ); - addTearDown(router.dispose); + ), + ); + await tester.pumpAndSettle(); - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - builder: (context, child) => - AppComponentThemes(child: child ?? const SizedBox.shrink()), - routerConfig: router, - ), - ), - ); - await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.history)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, + tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, + ); + final alphaItem = find.byKey(const ValueKey('create-history-item-https://example.com/alpha.zip')); + final betaItem = find.byKey(const ValueKey('create-history-item-https://example.com/beta.iso')); + final gammaItem = find.byKey(const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma')); + expect(alphaItem, findsOneWidget); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + final clearAction = find.byKey(const ValueKey('create-history-clear')); + final closeAction = find.byKey(const ValueKey('create-history-close')); + expect( + tester.widget(find.ancestor(of: clearAction, matching: find.byType(AppTooltip))).message, + 'Clear History', + ); + expect( + tester.widget(find.ancestor(of: closeAction, matching: find.byType(AppTooltip))).message, + 'Close', + ); + final historyText = tester.widget(find.descendant(of: alphaItem, matching: find.byType(Text))); + expect(historyText.data, contains('\u200B')); + expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); - final connectionsInput = find.byKey( - const ValueKey('create-task-connections-input'), - ); - expect(connectionsInput, findsOneWidget); - expect( - tester.getSize(connectionsInput).width, - AppDesignTokens.settingsNumberControlWidth, - ); - final connectionsField = tester.widget(connectionsInput); - expect(connectionsField.controller!.text, '12'); - expect(connectionsField.features.single, isA()); + final filter = find.byKey(const ValueKey('create-history-filter')); + await tester.enterText(filter, 'beta'); + await tester.pump(); + expect(alphaItem, findsNothing); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsNothing); - final advancedSection = find.byKey( - const ValueKey('create-task-advanced-section'), - ); - expect( - tester.widget(advancedSection).crossFadeState, - CrossFadeState.showSecond, - ); - await tester.tap(find.text('Advanced')); - await tester.pumpAndSettle(); - expect( - tester.widget(advancedSection).crossFadeState, - CrossFadeState.showFirst, - ); + await tester.tap(find.byKey(const ValueKey('create-history-delete-https://example.com/beta.iso'))); + await tester.pumpAndSettle(); + expect(removedHistory, 'https://example.com/beta.iso'); + expect(find.text('No History Found'), findsOneWidget); - expect( - _fieldText(tester, 'create-task-http-header-name-0'), - 'User-Agent', - ); - expect( - _fieldText(tester, 'create-task-http-header-value-0'), - 'Browser UA', - ); - expect( - _fieldText(tester, 'create-task-http-header-name-3'), - 'Authorization', - ); - expect( - _fieldText(tester, 'create-task-http-header-value-3'), - 'Bearer token', - ); - expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); - expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); - expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); - expect( - _fieldText(tester, 'create-task-proxy-password'), - 'proxy-password', - ); - expect( - _fieldText(tester, 'create-task-archive-password'), - 'archive-password', - ); - expect( - find.byKey(const ValueKey('create-task-skip-verify-cert')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-auto-torrent')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-delete-torrent')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-auto-extract')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-delete-after-extract')), - findsOneWidget, - ); + await tester.enterText(filter, ''); + await tester.pump(); + expect(alphaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); - expect(find.text('Archives'), findsNWidgets(2)); - expect(find.text('Quick folders'), findsNothing); - expect( - find.byKey(const ValueKey('create-task-category-0')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-category-1')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-category-2')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-task-category-3')), - findsOneWidget, - ); - final shortcutRow = find.byKey( - const ValueKey('create-task-directory-shortcuts'), - ); - expect(tester.getSize(shortcutRow).height, 28); - final rememberDirectory = find.byKey( - const ValueKey('create-task-remember-download-directory-checkbox'), - ); - final directDownloadCheckbox = find.byKey( - const ValueKey('create-task-direct-download-checkbox'), - ); - final rememberDirectoryRow = find.byKey( - const ValueKey('create-task-remember-download-directory'), - ); - final rememberDirectoryLabel = find.byKey( - const ValueKey('create-task-remember-download-directory-label'), - ); - final directDownloadLabel = find.descendant( - of: find.byKey(const ValueKey('create-task-direct-download-toggle')), - matching: find.text('Skip resolving and create tasks immediately'), - ); - final directoryComponent = find.byKey( - const ValueKey('create-task-directory-component'), - ); - final directoryOptionsRow = find.byKey( - const ValueKey('create-task-directory-options-row'), - ); - final directoryOptionsLayout = find.byKey( - const ValueKey('create-task-directory-options-layout'), - ); - final directoryPicker = find.byKey( - const ValueKey('create-task-directory-picker'), - ); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - expect(find.text('Save as default download directory'), findsOneWidget); - final rememberStyle = tester.widget(rememberDirectoryLabel).style!; - final directStyle = tester.widget(directDownloadLabel).style!; - expect(rememberStyle.color, directStyle.color); - expect(rememberStyle.fontSize, directStyle.fontSize); - expect(rememberStyle.fontWeight, directStyle.fontWeight); - expect(tester.widget(rememberDirectory).size, isNull); - expect( - tester.getSize(rememberDirectory), - const Size.square(AppDesignTokens.checkboxSize), - ); - expect( - tester.getCenter(rememberDirectory).dx, - closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5), - ); - final rememberDirectoryText = tester.widget(rememberDirectoryLabel); - final directDownloadText = tester.widget(directDownloadLabel); - expect(rememberDirectoryText.style, directDownloadText.style); - expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); - expect(rememberDirectoryText.overflow, directDownloadText.overflow); - expect( - tester.widget(directoryPicker).variance, - same(shad.ButtonVariance.outline), - ); - expect( - tester.widget(directoryOptionsLayout).mainAxisAlignment, - MainAxisAlignment.spaceBetween, - ); - expect( - tester.getCenter(rememberDirectoryRow).dy, - closeTo(tester.getCenter(shortcutRow).dy, 0.5), - ); - expect( - tester.getTopRight(directoryOptionsRow).dx, - closeTo(tester.getTopRight(directoryComponent).dx, 0.5), - ); - expect( - tester - .getTopRight(find.byKey(const ValueKey('create-task-category-3'))) - .dx, - closeTo(tester.getTopRight(directoryComponent).dx, 0.5), - ); - - tester.view.physicalSize = const Size(520, 720); - await tester.pumpAndSettle(); - final shortcutContent = find.byKey( - const ValueKey('create-task-directory-shortcuts-content'), - ); - expect( - tester.getTopLeft(shortcutRow).dy, - greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy), - ); - expect( - tester.getSize(shortcutRow).width, - closeTo(tester.getSize(directoryComponent).width, 0.5), - ); - expect( - tester.getSize(shortcutContent).width, - greaterThan(tester.getSize(shortcutRow).width), - ); - final shortcutScrollable = find.descendant( - of: shortcutRow, - matching: find.byType(Scrollable), - ); - expect( - tester - .state(shortcutScrollable) - .position - .maxScrollExtent, - greaterThan(0), - ); - - tester.view.physicalSize = const Size(719, 720); - await tester.pumpAndSettle(); - expect( - tester.widget( - find.byKey(const ValueKey('create-task-directory-options-layout')), - ), - isA(), - ); - - tester.view.physicalSize = const Size(720, 720); - await tester.pumpAndSettle(); - expect( - tester - .widget( - find.byKey( - const ValueKey('create-task-directory-options-layout'), - ), - ) - .mainAxisAlignment, - MainAxisAlignment.spaceBetween, - ); - - tester.view.physicalSize = const Size(1024, 720); - await tester.pumpAndSettle(); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-app-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-downloads-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.checked, - ); - - expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); - await tester.pump(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - expect( - find.descendant( - of: find.byKey(const ValueKey('create-task-category-3')), - matching: find.byIcon(Icons.folder_outlined), - ), - findsOneWidget, - ); - - androidLocations['downloads'] = 'C:/Downloads'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-downloads-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.unchecked, - ); - - androidLocations['downloads'] = 'G:/Manually selected'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('android-downloads-directory-option')), - ); - await tester.pumpAndSettle(); - expect( - tester.widget(rememberDirectory).state, - shad.CheckboxState.checked, - ); - - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - - final request = submitted?.req; - expect(request, isNotNull); - expect(request?.rawUrl, initialTask.req?.rawUrl); - expect(request?.labels, initialTask.req?.labels); - expect(request?.proxy?.mode, RequestProxyMode.custom); - expect(request?.proxy?.scheme, 'socks5'); - expect(request?.proxy?.host, '127.0.0.1:7890'); - expect(request?.proxy?.usr, 'proxy-user'); - expect(request?.proxy?.pwd, 'proxy-password'); - expect(request?.skipVerifyCert, isTrue); - expect( - ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), - initialTask.req?.extra, - ); - expect(submitted?.opts?.path, 'G:/Manually selected'); - final options = OptsExtraHttp.fromJson( - submitted?.opts?.extra! as Map, - ); - expect(options.connections, 12); - expect(options.autoTorrent, isTrue); - expect(options.deleteTorrentAfterDownload, isTrue); - expect(options.autoExtract, isTrue); - expect(options.archivePassword, 'archive-password'); - expect(options.deleteAfterExtract, isTrue); - expect(submitted?.opts?.asDefaultPath, isTrue); - }, - ); - - testWidgets( - 'create history supports filtering, deleting one entry, and clearing all entries', - (WidgetTester tester) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final histories = [ - 'https://example.com/alpha.zip', - 'https://example.com/beta.iso', - 'magnet:?xt=urn:btih:gamma', - ]; - String? removedHistory; - var cleared = false; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind( - GopeedMethods.getConfig, - (_) => DownloaderConfig(downloadDir: '/downloads'), - ) - ..bind( - StorageMethods.getCreateHistory, - (_) => List.unmodifiable(histories), - ) - ..bind(StorageMethods.removeCreateHistory, (value) { - removedHistory = value; - histories.remove(value); - return const RpcUnit(); - }) - ..bind(StorageMethods.clearCreateHistory, (_) { - cleared = true; - histories.clear(); - return const RpcUnit(); - }); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: AppComponentThemes(child: const CreateTaskWindowPage()), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.history)); - await tester.pumpAndSettle(); - expect( - tester - .getSize(find.byKey(const ValueKey('create-history-title-bar'))) - .width, - tester - .getSize(find.byKey(const ValueKey('create-history-content'))) - .width, - ); - final alphaItem = find.byKey( - const ValueKey('create-history-item-https://example.com/alpha.zip'), - ); - final betaItem = find.byKey( - const ValueKey('create-history-item-https://example.com/beta.iso'), - ); - final gammaItem = find.byKey( - const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma'), - ); - expect(alphaItem, findsOneWidget); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); - final clearAction = find.byKey(const ValueKey('create-history-clear')); - final closeAction = find.byKey(const ValueKey('create-history-close')); - expect( - tester - .widget( - find.ancestor(of: clearAction, matching: find.byType(AppTooltip)), - ) - .message, - 'Clear History', - ); - expect( - tester - .widget( - find.ancestor(of: closeAction, matching: find.byType(AppTooltip)), - ) - .message, - 'Close', - ); - final historyText = tester.widget( - find.descendant(of: alphaItem, matching: find.byType(Text)), - ); - expect(historyText.data, contains('\u200B')); - expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); - - final filter = find.byKey(const ValueKey('create-history-filter')); - await tester.enterText(filter, 'beta'); - await tester.pump(); - expect(alphaItem, findsNothing); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsNothing); - - await tester.tap( - find.byKey( - const ValueKey('create-history-delete-https://example.com/beta.iso'), - ), - ); - await tester.pumpAndSettle(); - expect(removedHistory, 'https://example.com/beta.iso'); - expect(find.text('No History Found'), findsOneWidget); - - await tester.enterText(filter, ''); - await tester.pump(); - expect(alphaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('create-history-clear'))); - await tester.pumpAndSettle(); - expect(cleared, isTrue); - expect(find.text('No history'), findsOneWidget); - expect( - find.byKey(const ValueKey('create-history-clear')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('create-history-close')), - findsOneWidget, - ); - }, - ); + await tester.tap(find.byKey(const ValueKey('create-history-clear'))); + await tester.pumpAndSettle(); + expect(cleared, isTrue); + expect(find.text('No history'), findsOneWidget); + expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); + expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); + }); } String _fieldText(WidgetTester tester, String key) { - final editable = find.descendant( - of: find.byKey(ValueKey(key)), - matching: find.byType(EditableText), - ); + final editable = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(EditableText)); return tester.widget(editable).controller.text; } diff --git a/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart new file mode 100644 index 00000000..4a8d5940 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/resolve_task_actions.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final width in [390.0, 1024.0]) { + for (final language in ['en', 'zh', 'de']) { + testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async { + tester.view.physicalSize = Size(width, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var submitting = false; + var cancelled = false; + await tester.pumpWidget( + shad.ShadcnApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Localizations.override( + context: context, + locale: Locale(language), + delegates: const [AppLocalizations.delegate], + child: AppComponentThemes( + child: Center( + child: StatefulBuilder( + builder: (context, setState) => shad.AlertDialog( + padding: const EdgeInsets.all(18), + content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100), + actions: [ + Flexible( + child: ResolveTaskActions( + submitting: submitting, + onCancel: () => cancelled = true, + onCreate: () => setState(() => submitting = true), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final cancel = find.byKey(const ValueKey('resolve-cancel-button')); + final create = find.byKey(const ValueKey('resolve-create-button')); + final cancelRect = tester.getRect(cancel); + final createRect = tester.getRect(create); + expect(cancelRect.size, createRect.size); + expect(cancelRect.center.dy, createRect.center.dy); + expect(cancelRect.left, greaterThanOrEqualTo(0)); + expect(createRect.right, lessThanOrEqualTo(width)); + expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget); + await tester.tap(cancel); + expect(cancelled, isTrue); + await tester.tap(create); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.widget(create).loading, isTrue); + expect(tester.widget(cancel).onPressed, isNull); + expect(tester.getRect(cancel), cancelRect); + expect(tester.getRect(create), createRect); + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/ui/flutter/test/shared/theme/app_component_themes_test.dart b/ui/flutter/test/shared/theme/app_component_themes_test.dart index 9080b1e2..b78b0235 100644 --- a/ui/flutter/test/shared/theme/app_component_themes_test.dart +++ b/ui/flutter/test/shared/theme/app_component_themes_test.dart @@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { + testWidgets('provides the standard text field appearance', (tester) async { + late shad.TextFieldTheme textFieldTheme; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) { + textFieldTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(textFieldTheme.filled, isTrue); + expect(textFieldTheme.border, Border.all(color: AppPalette.light.border)); + expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius)); + expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding); + }); + testWidgets('provides the standard checkbox metrics', (tester) async { late shad.CheckboxTheme checkboxTheme; diff --git a/ui/flutter/test/shared/widgets/app_loading_button_test.dart b/ui/flutter/test/shared/widgets/app_loading_button_test.dart index 1726cfd6..8d3f1ded 100644 --- a/ui/flutter/test/shared/widgets/app_loading_button_test.dart +++ b/ui/flutter/test/shared/widgets/app_loading_button_test.dart @@ -134,7 +134,10 @@ void main() { of: find.byKey(const ValueKey('text-only-loading-button')), matching: find.byType(shad.CircularProgressIndicator), ); - expect(find.text('Login'), findsNothing); + final hiddenLabel = find.text('Login'); + expect(hiddenLabel, findsOneWidget); + expect(tester.widget(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0); + expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget); expect(loader, findsOneWidget); expect(tester.getRect(loader).center, bounds.center); }); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index fc18a5a5..b90329cc 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -449,7 +449,7 @@ void main() { child: shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + home: const AppComponentThemes(child: ExtensionsPage()), ), ), ); @@ -469,6 +469,7 @@ void main() { final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0'))); final rightmostCardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))); final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark'))); + expect(searchRect.height, closeTo(sortRect.height, 0.01)); expect(searchRect.width, 240); expect(searchRect.width, lessThan(cardRect.width)); expect(sortRect.left - searchRect.right, closeTo(10, 0.01)); @@ -501,6 +502,7 @@ void main() { expect(find.byType(PrimaryRail), findsOneWidget); final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container'))); expect(compactSearchRect.width, lessThan(240)); + expect(compactSearchRect.height, closeTo(sortRect.height, 0.01)); expect( tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).center.dy, closeTo(compactSearchRect.center.dy, 0.01), @@ -745,7 +747,7 @@ void main() { child: shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + home: const AppComponentThemes(child: ExtensionsPage()), ), ), ); @@ -1895,6 +1897,40 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('loading button preserves its content dimensions', (WidgetTester tester) async { + var loading = false; + late StateSetter setButtonState; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Center( + child: StatefulBuilder( + builder: (context, update) { + setButtonState = update; + return AppLoadingButton( + key: const ValueKey('dimension-stable-loading-button'), + onPressed: () {}, + loading: loading, + variant: AppLoadingButtonVariant.primary, + child: const Text('Confirm'), + ); + }, + ), + ), + ), + ); + await tester.pump(); + final button = find.byKey(const ValueKey('dimension-stable-loading-button')); + final idleSize = tester.getSize(button); + + setButtonState(() => loading = true); + await tester.pump(); + + expect(tester.getSize(button), idleSize); + expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + }); + testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async { await _setTestSize(tester, const Size(1024, 900)); final runtimeController = FailingTrackerRuntimeController(); @@ -2337,7 +2373,9 @@ void main() { await tester.pumpAndSettle(); final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input')); final createRenameInput = find.byKey(const ValueKey('create-task-rename-input')); + final createConnectionsInput = find.byKey(const ValueKey('create-task-connections-input')); expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height); + expect(tester.getSize(createConnectionsInput), tester.getSize(createRenameInput)); final createDirectoryField = tester.widget(createDirectoryInput); final createRenameField = tester.widget( find.descendant(of: createRenameInput, matching: find.byType(AppTextField)), @@ -2439,6 +2477,27 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('create task rename stays user-controlled and resets when the URL changes', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 500)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pump(); + + final urlInput = find.byKey(const ValueKey('create-task-url-input')); + final renameInput = find.byKey(const ValueKey('create-task-rename-input')); + await tester.enterText(urlInput, 'https://example.com/first.zip'); + await tester.enterText(renameInput, 'custom-name.zip'); + expect(find.text('custom-name.zip'), findsOneWidget); + + await tester.enterText(urlInput, 'https://example.com/second.zip'); + await tester.pump(); + + final renameField = tester.widget( + find.descendant(of: renameInput, matching: find.byType(AppTextField)), + ); + expect(renameField.controller!.text, isEmpty); + await tester.pump(const Duration(seconds: 1)); + }); + testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async { await _setTestSize(tester, const Size(700, 500)); await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); @@ -2626,6 +2685,7 @@ void main() { expect(treeFinder, findsOneWidget); final tree = tester.widget>(treeFinder); expect(tree.branchLine, same(shad.BranchLine.path)); + expect(tree.rowHeight, 36); expect( tester.widgetList(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null), isTrue, @@ -4854,7 +4914,7 @@ class _CreateTaskPageHarness extends StatelessWidget { return shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const CreateTaskWindowPage(), + home: const AppComponentThemes(child: CreateTaskWindowPage()), ); } } From 5f89ae35164ee63f13604be428eb43ab9d7a862f Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:03:43 +0800 Subject: [PATCH 02/12] fix: unify platform window titles as Gopeed (#1494) --- ui/flutter/lib/app/app.dart | 2 +- ui/flutter/lib/core/window/app_window_bootstrap.dart | 5 +++-- ui/flutter/linux/runner/my_application.cc | 4 ++-- ui/flutter/macos/Runner/Base.lproj/MainMenu.xib | 2 +- ui/flutter/test/core/window/app_window_payload_test.dart | 4 ++-- ui/flutter/web/manifest.json | 4 ++-- ui/flutter/windows/runner/main.cpp | 4 ++-- 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ui/flutter/lib/app/app.dart b/ui/flutter/lib/app/app.dart index 0a0e3b33..33531d28 100644 --- a/ui/flutter/lib/app/app.dart +++ b/ui/flutter/lib/app/app.dart @@ -79,7 +79,7 @@ class _GopeedAppState extends ConsumerState with WidgetsBindingObserv return shad.ShadcnApp.router( debugShowCheckedModeBanner: false, disableBrowserContextMenu: false, - onGenerateTitle: (context) => context.l10n.appTitle, + title: 'Gopeed', theme: AppTheme.light(themeColor), darkTheme: AppTheme.dark(themeColor), materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor), diff --git a/ui/flutter/lib/core/window/app_window_bootstrap.dart b/ui/flutter/lib/core/window/app_window_bootstrap.dart index 275e58c8..e2b29316 100644 --- a/ui/flutter/lib/core/window/app_window_bootstrap.dart +++ b/ui/flutter/lib/core/window/app_window_bootstrap.dart @@ -16,6 +16,7 @@ class AppWindowBootstrap { await windowManager.ensureInitialized(); final windowOptions = WindowOptions( + title: 'Gopeed', size: const Size(1024, 768), center: true, skipTaskbar: false, @@ -74,8 +75,8 @@ class AppWindowBootstrap { static String subWindowTitle(AppWindowType type, AppLocalizations l10n) { return switch (type) { - AppWindowType.createTask => '${l10n.create} - Gopeed', - _ => l10n.appTitle, + AppWindowType.createTask => 'Gopeed - ${l10n.create}', + _ => 'Gopeed', }; } diff --git a/ui/flutter/linux/runner/my_application.cc b/ui/flutter/linux/runner/my_application.cc index d6355099..895f8fd1 100644 --- a/ui/flutter/linux/runner/my_application.cc +++ b/ui/flutter/linux/runner/my_application.cc @@ -48,11 +48,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "gopeed"); + gtk_header_bar_set_title(header_bar, "Gopeed"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "gopeed"); + gtk_window_set_title(window, "Gopeed"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib index 80e867a4..2070a6c6 100644 --- a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib +++ b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib @@ -330,7 +330,7 @@ - + diff --git a/ui/flutter/test/core/window/app_window_payload_test.dart b/ui/flutter/test/core/window/app_window_payload_test.dart index 9b71c2b3..d4bdd090 100644 --- a/ui/flutter/test/core/window/app_window_payload_test.dart +++ b/ui/flutter/test/core/window/app_window_payload_test.dart @@ -9,9 +9,9 @@ void main() { test('create-task child window has a localized native title', () { expect( AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')), - 'Create Task - Gopeed', + 'Gopeed-Create Task', ); - expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed'); + expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务'); }); test('create-task payload only carries initial task data', () { diff --git a/ui/flutter/web/manifest.json b/ui/flutter/web/manifest.json index 3e285875..d75201fc 100644 --- a/ui/flutter/web/manifest.json +++ b/ui/flutter/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "gopeed", - "short_name": "gopeed", + "name": "Gopeed", + "short_name": "Gopeed", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/ui/flutter/windows/runner/main.cpp b/ui/flutter/windows/runner/main.cpp index c8c65677..74a39e96 100644 --- a/ui/flutter/windows/runner/main.cpp +++ b/ui/flutter/windows/runner/main.cpp @@ -56,7 +56,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); if (!IsMultiWindowLaunch(command_line_arguments) && - SendAppLinkToInstance(L"gopeed")) { + SendAppLinkToInstance(L"Gopeed")) { return EXIT_SUCCESS; } @@ -71,7 +71,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"gopeed", origin, size)) { + if (!window.Create(L"Gopeed", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); From 36f9f65384afef6e45b7906dfd9f9f8a31e2b126 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:05:15 +0800 Subject: [PATCH 03/12] fix(android): request notification permission on startup (#1497) --- .../lib/app/application/android_foreground_service.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/flutter/lib/app/application/android_foreground_service.dart b/ui/flutter/lib/app/application/android_foreground_service.dart index 479b7363..9efd4da8 100644 --- a/ui/flutter/lib/app/application/android_foreground_service.dart +++ b/ui/flutter/lib/app/application/android_foreground_service.dart @@ -28,6 +28,13 @@ class AndroidForegroundService { ), ); + // Android 13+ requires runtime permission to show the service notification. + // A denial only hides it from the notification drawer; the service can run. + final permission = await FlutterForegroundTask.checkNotificationPermission(); + if (permission == NotificationPermission.denied) { + await FlutterForegroundTask.requestNotificationPermission(); + } + if (await FlutterForegroundTask.isRunningService) { _throwOnFailure(await FlutterForegroundTask.restartService()); return; From 75ffe70a4d0e9cac1664c274a9053f4888ad3a70 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:50:09 +0800 Subject: [PATCH 04/12] fix(windows): use bundled PNG for notification header icon (#1499) --- .../app_notification_controller.dart | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 5ca4efb8..bd09d189 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -2,14 +2,14 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; import '../../core/common/task_event.dart'; import '../../core/libgopeed_boot.dart'; import '../../l10n/l10n.dart'; +import '../../util/log_util.dart'; import '../../util/util.dart'; import 'app_runtime_controller.dart'; @@ -56,13 +56,27 @@ class AppNotificationController extends AsyncNotifier { String? windowsIconPath; try { if (Util.isWindows()) { - final byteData = await rootBundle.load('assets/icon/icon.ico'); - final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/notification_icon.ico'); - await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)); - windowsIconPath = file.path; + // Windows requires a file path for IconUri, so use the bundled PNG + // beside the executable rather than copying it to another directory. + final file = File( + path.join( + path.dirname(Platform.resolvedExecutable), + 'data', + 'flutter_assets', + 'assets', + 'icon', + 'icon_512.png', + ), + ); + if (await file.exists()) { + windowsIconPath = file.absolute.path; + } else { + logger.w('Windows notification icon not found: ${file.path}'); + } } - } catch (_) {} + } catch (error, stackTrace) { + logger.w('prepare Windows notification icon failed', error, stackTrace); + } final windows = WindowsInitializationSettings( appName: 'Gopeed', From bb17c7d48a4dc0fd7586d5398265c7d1da9a4e49 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:50:50 +0800 Subject: [PATCH 05/12] fix(macos): request permission for download notifications (#1498) --- .../app_notification_controller.dart | 20 ++- .../app_notification_controller_test.dart | 139 ++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 ui/flutter/test/app/application/app_notification_controller_test.dart diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index bd09d189..cf8619cb 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -28,13 +28,19 @@ class AppNotificationController extends AsyncNotifier { StreamSubscription? _taskEventSubscription; var _notificationId = 0; + @visibleForTesting + Stream get taskEvents => LibgopeedBoot.instance.taskEvents; + @override Future build() async { final runtime = ref.watch(appRuntimeControllerProvider).value; if (runtime == null || kIsWeb || !Util.isDesktop()) { return const AppNotificationState(); } - await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); + await _initNotifications( + appLocalizationsFor(runtime.downloaderConfig.extra.locale), + requestPermissions: runtime.downloaderConfig.extra.desktopNotification, + ); _listenTaskEvents(); ref.onDispose(() { unawaited(_taskEventSubscription?.cancel()); @@ -42,11 +48,13 @@ class AppNotificationController extends AsyncNotifier { return const AppNotificationState(started: true); } - Future _initNotifications(AppLocalizations locale) async { - const darwin = DarwinInitializationSettings( - requestAlertPermission: false, + Future _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async { + // The runtime is watched, so enabling notifications in settings also + // requests authorization. macOS remembers previously granted/denied access. + final darwin = DarwinInitializationSettings( + requestAlertPermission: requestPermissions, requestBadgePermission: false, - requestSoundPermission: false, + requestSoundPermission: requestPermissions, ); final linux = LinuxInitializationSettings( defaultActionName: locale.open, @@ -92,7 +100,7 @@ class AppNotificationController extends AsyncNotifier { void _listenTaskEvents() { _taskEventSubscription?.cancel(); - _taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async { + _taskEventSubscription = taskEvents.listen((event) async { final runtime = ref.read(appRuntimeControllerProvider).value; if (runtime?.downloaderConfig.extra.desktopNotification == false) return; final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); diff --git a/ui/flutter/test/app/application/app_notification_controller_test.dart b/ui/flutter/test/app/application/app_notification_controller_test.dart new file mode 100644 index 00000000..a74c60d4 --- /dev/null +++ b/ui/flutter/test/app/application/app_notification_controller_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_notification_controller.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/common/task_event.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + final calls = []; + var permissionGranted = true; + late StreamController events; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + MacOSFlutterLocalNotificationsPlugin.registerWith(); + calls.clear(); + permissionGranted = true; + events = StreamController.broadcast(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return permissionGranted; + }); + }); + + tearDown(() { + unawaited(events.close()); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + Future start({required bool enabled}) async { + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)), + appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)), + ], + ); + addTearDown(container.dispose); + await container.read(appRuntimeControllerProvider.future); + await container.read(appNotificationControllerProvider.future); + return container; + } + + Map initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map; + + test('macOS requests alerts and sound when download notifications are enabled at startup', () async { + await start(enabled: true); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + expect(initialization()['requestBadgePermission'], isFalse); + }); + + test('disabled notifications defer authorization until the setting is enabled', () async { + final container = await start(enabled: false); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + }); + + test('denied macOS authorization does not fail notification controller startup', () async { + permissionGranted = false; + final container = await start(enabled: true); + + expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue); + }); + + test('terminal task events notify once after enabling and stop after disabling', () async { + final container = await start(enabled: false); + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip'); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), isEmpty); + + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip')); + await Future.delayed(Duration.zero); + final notifications = calls.where((call) => call.method == 'show').toList(); + expect(notifications, hasLength(2)); + expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), hasLength(2)); + }); +} + +class _TestNotificationController extends AppNotificationController { + _TestNotificationController(this.taskEvents); + + @override + final Stream taskEvents; +} + +class _TestRuntimeController extends AppRuntimeController { + _TestRuntimeController(this.enabled); + + final bool enabled; + + @override + Future build() async => _runtime(enabled); + + void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled)); + + AppRuntimeState _runtime(bool enabled) => AppRuntimeState( + startConfig: StartConfig(), + apiServerState: ApiServerState.fromJson({}), + downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled, + ); +} From 8f9bac9e6bc99250928ce211a45252e3f85d5f59 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:51:16 +0800 Subject: [PATCH 06/12] fix(ui): reuse shared file path picker for script settings (#1496) --- .../presentation/pages/settings_page.dart | 8 +--- .../widgets/settings_list_editor.dart | 10 ++--- ui/flutter/lib/main.dart | 6 +++ .../shared/widgets/app_path_picker_field.dart | 39 ++++++++++++++++++- .../widgets/app_path_picker_field_test.dart | 39 +++++++++++++++++++ 5 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 ui/flutter/test/shared/widgets/app_path_picker_field_test.dart diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index 62c2c7e3..67d41e74 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation; import 'package:flutter/widgets.dart'; @@ -1376,12 +1375,7 @@ class _SettingsPageState extends ConsumerState { title: index == null ? context.l10n.addScript : context.l10n.editScript, fieldLabel: context.l10n.scriptPath, initialValue: index == null ? '' : paths[index], - pickPath: Util.isDesktop() - ? () async { - final file = await FilePicker.pickFile(); - return file?.path; - } - : null, + pickFile: true, ); if (value == null || !mounted) return; _mutateConfig((config) { diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart index 56ae7c69..58b325ae 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -122,7 +122,7 @@ Future showTextSettingDialog( required String fieldLabel, String initialValue = '', bool requireHttpUrl = false, - Future Function()? pickPath, + bool pickFile = false, Future Function(String value)? onTest, }) async { final controller = TextEditingController(text: initialValue); @@ -189,16 +189,12 @@ Future showTextSettingDialog( children: [ Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - if (pickPath == null) + if (!pickFile) AppTextField(controller: controller) else - AppPathPickerField( + AppPathPickerField.file( controller: controller, desktopWidth: AppDesignTokens.settingsFormControlWidth, - onPick: () async { - final path = await pickPath(); - if (path != null && path.isNotEmpty) controller.text = path; - }, ), if (validationMessage != null || testMessage != null) ...[ const SizedBox(height: 8), diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 5783d4fd..e8caba80 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -17,6 +18,11 @@ import 'util/util.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + if (Util.isMacos()) { + // Gopeed is not sandboxed. Configure each engine before the main/child + // window split so native file and directory dialogs work in both windows. + await FilePicker.skipEntitlementsChecks(); + } if (Util.isAndroid()) { FlutterForegroundTask.initCommunicationPort(); } diff --git a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart index f7094532..e14ba37c 100644 --- a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart +++ b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart @@ -1,8 +1,10 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../core/utils/breakpoints.dart'; +import '../../util/util.dart'; import '../services/download_directory_picker.dart'; import 'app_text_field.dart'; import 'app_tooltip.dart'; @@ -24,7 +26,28 @@ class AppPathPickerField extends StatefulWidget { this.padding, this.onChanged, this.pickerButtonStyle = AppPathPickerButtonStyle.ghost, - }) : platformDownloadDirectory = false, + }) : platformFile = false, + platformDownloadDirectory = false, + onDirectoryPicked = null, + allowAndroidEditing = false; + + /// Selects a native desktop file path; Web and mobile accept manual paths only. + const AppPathPickerField.file({ + super.key, + required this.controller, + this.fieldKey, + this.pickerKey, + this.hintText, + this.desktopWidth, + this.filled, + this.border, + this.borderRadius, + this.padding, + this.onChanged, + this.pickerButtonStyle = AppPathPickerButtonStyle.outline, + }) : onPick = null, + platformFile = true, + platformDownloadDirectory = false, onDirectoryPicked = null, allowAndroidEditing = false; @@ -44,6 +67,7 @@ class AppPathPickerField extends StatefulWidget { this.onChanged, this.pickerButtonStyle = AppPathPickerButtonStyle.outline, }) : onPick = null, + platformFile = false, platformDownloadDirectory = true; final TextEditingController controller; @@ -58,6 +82,7 @@ class AppPathPickerField extends StatefulWidget { final EdgeInsetsGeometry? padding; final ValueChanged? onChanged; final AppPathPickerButtonStyle pickerButtonStyle; + final bool platformFile; final bool platformDownloadDirectory; final ValueChanged? onDirectoryPicked; final bool allowAndroidEditing; @@ -97,6 +122,16 @@ class _AppPathPickerFieldState extends State { setState(() => _tooltipPath = path); } + Future _pickFile() async { + if (!Util.isDesktop()) return; + final file = await FilePicker.pickFile(); + if (!mounted) return; + final path = file?.path; + if (path == null || path.isEmpty || path == widget.controller.text) return; + widget.controller.text = path; + widget.onChanged?.call(path); + } + Future _pickPlatformDirectory() async { final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim()); if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return; @@ -110,6 +145,8 @@ class _AppPathPickerFieldState extends State { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final onPick = widget.platformDownloadDirectory ? (DownloadDirectoryPicker.canPick ? _pickPlatformDirectory : null) + : widget.platformFile + ? (Util.isDesktop() ? _pickFile : null) : widget.onPick; return SizedBox( width: desktop ? widget.desktopWidth : double.infinity, diff --git a/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart b/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart new file mode 100644 index 00000000..78321a1e --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('file paths remain editable and Web hides native file selection', (tester) async { + final controller = TextEditingController(text: '/scripts/old.sh'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: AppPathPickerField.file( + controller: controller, + desktopWidth: 320, + pickerKey: const ValueKey('file-picker'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('file-picker')), kIsWeb ? findsNothing : findsOneWidget); + expect(tester.widget(find.byType(AppTextField)).readOnly, isFalse); + await tester.enterText(find.byType(EditableText), '/scripts/custom.sh'); + await tester.pump(); + expect(controller.text, '/scripts/custom.sh'); + expect(tester.takeException(), isNull); + }); +} From 08fe4eef4e274934405a9db37742d6e36ec885d5 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 11 Sep 2026 08:00:03 +0800 Subject: [PATCH 07/12] fix(ui): refine create task actions and relocate icon sources (#1500) --- .../gopeed-file-category-icons/.gitignore | 0 .../gopeed-file-category-icons/SKILL.md | 4 +- .../agents/openai.yaml | 0 .../assets/folder-symbol-template.svg | 0 .../assets/manifest.example.json | 0 .../references/style-guide.md | 0 .../scripts/build_icon_font.py | 0 .../scripts/preview_icon_font.py | 0 .../scripts/requirements.txt | 0 .../design}/file-category-icons/README.md | 6 +- .../design}/file-category-icons/manifest.json | 4 +- .../preview/gopeed_icons_preview.png | Bin .../file-category-icons/svg/file-android.svg | 0 .../file-category-icons/svg/file-archive.svg | 0 .../file-category-icons/svg/file-audio.svg | 0 .../file-category-icons/svg/file-code.svg | 0 .../file-category-icons/svg/file-database.svg | 0 .../svg/file-disk-image.svg | 0 .../file-category-icons/svg/file-document.svg | 0 .../file-category-icons/svg/file-ebook.svg | 0 .../file-category-icons/svg/file-font.svg | 0 .../file-category-icons/svg/file-image.svg | 0 .../svg/file-installer.svg | 0 .../file-category-icons/svg/file-ios.svg | 0 .../file-category-icons/svg/file-pdf.svg | 0 .../svg/file-presentation.svg | 0 .../svg/file-spreadsheet.svg | 0 .../file-category-icons/svg/file-text.svg | 0 .../file-category-icons/svg/file-video.svg | 0 .../file-category-icons/svg/file-web.svg | 0 .../design}/file-category-icons/svg/file.svg | 0 .../file-category-icons/svg/folder-bt.svg | 0 .../file-category-icons/svg/folder.svg | 0 .../file-category-icons/svg/protocol-bt.svg | 0 .../file-category-icons/svg/protocol-ed2k.svg | 0 .../pages/create_task_window_page.dart | 32 ++++---- .../widgets/create_task_action_buttons.dart | 56 +++++++++++++ .../widgets/resolve_file_tree.dart | 76 ++++++++++-------- .../widgets/resolve_task_actions.dart | 50 ------------ ui/flutter/lib/l10n/app_ar.arb | 2 +- ui/flutter/lib/l10n/app_az.arb | 2 +- ui/flutter/lib/l10n/app_ca.arb | 2 +- ui/flutter/lib/l10n/app_de.arb | 2 +- ui/flutter/lib/l10n/app_en.arb | 2 +- ui/flutter/lib/l10n/app_es.arb | 2 +- ui/flutter/lib/l10n/app_fa.arb | 2 +- ui/flutter/lib/l10n/app_fr.arb | 2 +- ui/flutter/lib/l10n/app_hu.arb | 2 +- ui/flutter/lib/l10n/app_id.arb | 2 +- ui/flutter/lib/l10n/app_it.arb | 2 +- ui/flutter/lib/l10n/app_ja.arb | 2 +- ui/flutter/lib/l10n/app_ko.arb | 2 +- ui/flutter/lib/l10n/app_pl.arb | 2 +- ui/flutter/lib/l10n/app_pt.arb | 2 +- ui/flutter/lib/l10n/app_ru.arb | 2 +- ui/flutter/lib/l10n/app_ta.arb | 2 +- ui/flutter/lib/l10n/app_tr.arb | 2 +- ui/flutter/lib/l10n/app_uk.arb | 2 +- ui/flutter/lib/l10n/app_vi.arb | 2 +- ui/flutter/lib/l10n/app_zh.arb | 2 +- ui/flutter/lib/l10n/app_zh_TW.arb | 2 +- .../create_task_window_page_test.dart | 12 +-- .../resolve_task_actions_test.dart | 13 ++- ui/flutter/test/widget_test.dart | 35 +++++++- 64 files changed, 193 insertions(+), 139 deletions(-) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/.gitignore (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/SKILL.md (94%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/agents/openai.yaml (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/assets/manifest.example.json (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/references/style-guide.md (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/scripts/build_icon_font.py (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/scripts/preview_icon_font.py (100%) rename {.agent => ui/flutter/.agent}/skills/gopeed-file-category-icons/scripts/requirements.txt (100%) rename {design => ui/flutter/design}/file-category-icons/README.md (75%) rename {design => ui/flutter/design}/file-category-icons/manifest.json (95%) rename {design => ui/flutter/design}/file-category-icons/preview/gopeed_icons_preview.png (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-android.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-archive.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-audio.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-code.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-database.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-disk-image.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-document.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-ebook.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-font.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-image.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-installer.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-ios.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-pdf.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-presentation.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-spreadsheet.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-text.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-video.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file-web.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/file.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/folder-bt.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/folder.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/protocol-bt.svg (100%) rename {design => ui/flutter/design}/file-category-icons/svg/protocol-ed2k.svg (100%) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart delete mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart diff --git a/.agent/skills/gopeed-file-category-icons/.gitignore b/ui/flutter/.agent/skills/gopeed-file-category-icons/.gitignore similarity index 100% rename from .agent/skills/gopeed-file-category-icons/.gitignore rename to ui/flutter/.agent/skills/gopeed-file-category-icons/.gitignore diff --git a/.agent/skills/gopeed-file-category-icons/SKILL.md b/ui/flutter/.agent/skills/gopeed-file-category-icons/SKILL.md similarity index 94% rename from .agent/skills/gopeed-file-category-icons/SKILL.md rename to ui/flutter/.agent/skills/gopeed-file-category-icons/SKILL.md index 02bf3027..f4fbaf9a 100644 --- a/.agent/skills/gopeed-file-category-icons/SKILL.md +++ b/ui/flutter/.agent/skills/gopeed-file-category-icons/SKILL.md @@ -7,7 +7,7 @@ description: Design or revise Gopeed file-classification icons as one cohesive S Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline. -The canonical editable materials live in `design/file-category-icons/`, outside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets. +The canonical editable materials live in `design/file-category-icons/` as tooling-only sources inside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets. ## Classification grammar @@ -49,7 +49,7 @@ Use [assets/manifest.example.json](assets/manifest.example.json) and [assets/fol ## Invariants - Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration. -- `design/file-category-icons/` is the sole editable source of truth. Flutter keeps only the generated runtime TTF and Dart constants. +- `design/file-category-icons/` is the sole editable source of truth. The packaged Flutter runtime uses only the generated TTF and Dart constants. - Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph. - The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font. - Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields. diff --git a/.agent/skills/gopeed-file-category-icons/agents/openai.yaml b/ui/flutter/.agent/skills/gopeed-file-category-icons/agents/openai.yaml similarity index 100% rename from .agent/skills/gopeed-file-category-icons/agents/openai.yaml rename to ui/flutter/.agent/skills/gopeed-file-category-icons/agents/openai.yaml diff --git a/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg b/ui/flutter/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg similarity index 100% rename from .agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg rename to ui/flutter/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg diff --git a/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json b/ui/flutter/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json similarity index 100% rename from .agent/skills/gopeed-file-category-icons/assets/manifest.example.json rename to ui/flutter/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json diff --git a/.agent/skills/gopeed-file-category-icons/references/style-guide.md b/ui/flutter/.agent/skills/gopeed-file-category-icons/references/style-guide.md similarity index 100% rename from .agent/skills/gopeed-file-category-icons/references/style-guide.md rename to ui/flutter/.agent/skills/gopeed-file-category-icons/references/style-guide.md diff --git a/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py b/ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py similarity index 100% rename from .agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py rename to ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py diff --git a/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py b/ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py similarity index 100% rename from .agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py rename to ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py diff --git a/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt b/ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt similarity index 100% rename from .agent/skills/gopeed-file-category-icons/scripts/requirements.txt rename to ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt diff --git a/design/file-category-icons/README.md b/ui/flutter/design/file-category-icons/README.md similarity index 75% rename from design/file-category-icons/README.md rename to ui/flutter/design/file-category-icons/README.md index 821a8513..3f806947 100644 --- a/design/file-category-icons/README.md +++ b/ui/flutter/design/file-category-icons/README.md @@ -1,6 +1,6 @@ # 文件分类图标源物料 -此目录是 Gopeed 文件分类图标的唯一源物料目录,位于 Flutter 工程之外,不得加入 `ui/flutter/pubspec.yaml` 的 `assets` 配置。 +此目录是 Gopeed 文件分类图标的唯一源物料目录。它位于 Flutter 工程内,但只用于设计和生成流程,不得加入 `pubspec.yaml` 的 `assets` 配置。 ## 维护限制 @@ -17,8 +17,8 @@ ## 生成产物 -- Flutter 字体:`ui/flutter/assets/fonts/GopeedIcons.ttf` -- Flutter 常量:`ui/flutter/lib/core/icons/gopeed_icons.dart` +- Flutter 字体:`assets/fonts/GopeedIcons.ttf` +- Flutter 常量:`lib/core/icons/gopeed_icons.dart` 所有修改都应遵循: diff --git a/design/file-category-icons/manifest.json b/ui/flutter/design/file-category-icons/manifest.json similarity index 95% rename from design/file-category-icons/manifest.json rename to ui/flutter/design/file-category-icons/manifest.json index 50c95c9f..60457fd8 100644 --- a/design/file-category-icons/manifest.json +++ b/ui/flutter/design/file-category-icons/manifest.json @@ -1,7 +1,7 @@ { "fontFamily": "GopeedIcons", - "fontOutput": "../../ui/flutter/assets/fonts/GopeedIcons.ttf", - "dartOutput": "../../ui/flutter/lib/core/icons/gopeed_icons.dart", + "fontOutput": "../../assets/fonts/GopeedIcons.ttf", + "dartOutput": "../../lib/core/icons/gopeed_icons.dart", "previewOutput": "preview/gopeed_icons_preview.png", "dartClass": "GopeedIcons", "codepointStart": "0xE900", diff --git a/design/file-category-icons/preview/gopeed_icons_preview.png b/ui/flutter/design/file-category-icons/preview/gopeed_icons_preview.png similarity index 100% rename from design/file-category-icons/preview/gopeed_icons_preview.png rename to ui/flutter/design/file-category-icons/preview/gopeed_icons_preview.png diff --git a/design/file-category-icons/svg/file-android.svg b/ui/flutter/design/file-category-icons/svg/file-android.svg similarity index 100% rename from design/file-category-icons/svg/file-android.svg rename to ui/flutter/design/file-category-icons/svg/file-android.svg diff --git a/design/file-category-icons/svg/file-archive.svg b/ui/flutter/design/file-category-icons/svg/file-archive.svg similarity index 100% rename from design/file-category-icons/svg/file-archive.svg rename to ui/flutter/design/file-category-icons/svg/file-archive.svg diff --git a/design/file-category-icons/svg/file-audio.svg b/ui/flutter/design/file-category-icons/svg/file-audio.svg similarity index 100% rename from design/file-category-icons/svg/file-audio.svg rename to ui/flutter/design/file-category-icons/svg/file-audio.svg diff --git a/design/file-category-icons/svg/file-code.svg b/ui/flutter/design/file-category-icons/svg/file-code.svg similarity index 100% rename from design/file-category-icons/svg/file-code.svg rename to ui/flutter/design/file-category-icons/svg/file-code.svg diff --git a/design/file-category-icons/svg/file-database.svg b/ui/flutter/design/file-category-icons/svg/file-database.svg similarity index 100% rename from design/file-category-icons/svg/file-database.svg rename to ui/flutter/design/file-category-icons/svg/file-database.svg diff --git a/design/file-category-icons/svg/file-disk-image.svg b/ui/flutter/design/file-category-icons/svg/file-disk-image.svg similarity index 100% rename from design/file-category-icons/svg/file-disk-image.svg rename to ui/flutter/design/file-category-icons/svg/file-disk-image.svg diff --git a/design/file-category-icons/svg/file-document.svg b/ui/flutter/design/file-category-icons/svg/file-document.svg similarity index 100% rename from design/file-category-icons/svg/file-document.svg rename to ui/flutter/design/file-category-icons/svg/file-document.svg diff --git a/design/file-category-icons/svg/file-ebook.svg b/ui/flutter/design/file-category-icons/svg/file-ebook.svg similarity index 100% rename from design/file-category-icons/svg/file-ebook.svg rename to ui/flutter/design/file-category-icons/svg/file-ebook.svg diff --git a/design/file-category-icons/svg/file-font.svg b/ui/flutter/design/file-category-icons/svg/file-font.svg similarity index 100% rename from design/file-category-icons/svg/file-font.svg rename to ui/flutter/design/file-category-icons/svg/file-font.svg diff --git a/design/file-category-icons/svg/file-image.svg b/ui/flutter/design/file-category-icons/svg/file-image.svg similarity index 100% rename from design/file-category-icons/svg/file-image.svg rename to ui/flutter/design/file-category-icons/svg/file-image.svg diff --git a/design/file-category-icons/svg/file-installer.svg b/ui/flutter/design/file-category-icons/svg/file-installer.svg similarity index 100% rename from design/file-category-icons/svg/file-installer.svg rename to ui/flutter/design/file-category-icons/svg/file-installer.svg diff --git a/design/file-category-icons/svg/file-ios.svg b/ui/flutter/design/file-category-icons/svg/file-ios.svg similarity index 100% rename from design/file-category-icons/svg/file-ios.svg rename to ui/flutter/design/file-category-icons/svg/file-ios.svg diff --git a/design/file-category-icons/svg/file-pdf.svg b/ui/flutter/design/file-category-icons/svg/file-pdf.svg similarity index 100% rename from design/file-category-icons/svg/file-pdf.svg rename to ui/flutter/design/file-category-icons/svg/file-pdf.svg diff --git a/design/file-category-icons/svg/file-presentation.svg b/ui/flutter/design/file-category-icons/svg/file-presentation.svg similarity index 100% rename from design/file-category-icons/svg/file-presentation.svg rename to ui/flutter/design/file-category-icons/svg/file-presentation.svg diff --git a/design/file-category-icons/svg/file-spreadsheet.svg b/ui/flutter/design/file-category-icons/svg/file-spreadsheet.svg similarity index 100% rename from design/file-category-icons/svg/file-spreadsheet.svg rename to ui/flutter/design/file-category-icons/svg/file-spreadsheet.svg diff --git a/design/file-category-icons/svg/file-text.svg b/ui/flutter/design/file-category-icons/svg/file-text.svg similarity index 100% rename from design/file-category-icons/svg/file-text.svg rename to ui/flutter/design/file-category-icons/svg/file-text.svg diff --git a/design/file-category-icons/svg/file-video.svg b/ui/flutter/design/file-category-icons/svg/file-video.svg similarity index 100% rename from design/file-category-icons/svg/file-video.svg rename to ui/flutter/design/file-category-icons/svg/file-video.svg diff --git a/design/file-category-icons/svg/file-web.svg b/ui/flutter/design/file-category-icons/svg/file-web.svg similarity index 100% rename from design/file-category-icons/svg/file-web.svg rename to ui/flutter/design/file-category-icons/svg/file-web.svg diff --git a/design/file-category-icons/svg/file.svg b/ui/flutter/design/file-category-icons/svg/file.svg similarity index 100% rename from design/file-category-icons/svg/file.svg rename to ui/flutter/design/file-category-icons/svg/file.svg diff --git a/design/file-category-icons/svg/folder-bt.svg b/ui/flutter/design/file-category-icons/svg/folder-bt.svg similarity index 100% rename from design/file-category-icons/svg/folder-bt.svg rename to ui/flutter/design/file-category-icons/svg/folder-bt.svg diff --git a/design/file-category-icons/svg/folder.svg b/ui/flutter/design/file-category-icons/svg/folder.svg similarity index 100% rename from design/file-category-icons/svg/folder.svg rename to ui/flutter/design/file-category-icons/svg/folder.svg diff --git a/design/file-category-icons/svg/protocol-bt.svg b/ui/flutter/design/file-category-icons/svg/protocol-bt.svg similarity index 100% rename from design/file-category-icons/svg/protocol-bt.svg rename to ui/flutter/design/file-category-icons/svg/protocol-bt.svg diff --git a/design/file-category-icons/svg/protocol-ed2k.svg b/ui/flutter/design/file-category-icons/svg/protocol-ed2k.svg similarity index 100% rename from design/file-category-icons/svg/protocol-ed2k.svg rename to ui/flutter/design/file-category-icons/svg/protocol-ed2k.svg 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 05d37d9b..55fb3ad1 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,7 +29,6 @@ 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_text_field.dart'; @@ -38,8 +37,8 @@ import '../../../../shared/widgets/app_toast.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; +import '../widgets/create_task_action_buttons.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}); @@ -696,17 +695,14 @@ class _CreateTaskWindowPageState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SecondaryButton( - onPressed: _creating ? null : _closeWindow, - child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))), - ), - const SizedBox(width: 12), - 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))), + CreateTaskActionButtons( + submitting: _creating, + onCancel: _closeWindow, + onSubmit: _confirm, + cancelLabel: context.l10n.cancel, + submitLabel: context.l10n.confirm, + cancelButtonKey: const ValueKey('create-task-cancel-button'), + submitButtonKey: const ValueKey('create-task-confirm-button'), ), ], ), @@ -1159,10 +1155,14 @@ class _CreateTaskWindowPageState extends ConsumerState { ), actions: [ flutter.Flexible( - child: ResolveTaskActions( + child: CreateTaskActionButtons( submitting: submitting, onCancel: () => closeOverlay(dialogContext, false), - onCreate: submit, + onSubmit: submit, + cancelLabel: dialogContext.l10n.cancel, + submitLabel: dialogContext.l10n.createAction, + cancelButtonKey: const ValueKey('resolve-cancel-button'), + submitButtonKey: const ValueKey('resolve-create-button'), ), ), ], @@ -1205,7 +1205,7 @@ class _CreateTaskWindowPageState extends ConsumerState { width: contentWidth, child: Row( children: [ - Expanded(child: Text(dialogContext.l10n.createHistory)), + Expanded(child: Text(dialogContext.l10n.historyLinks)), AppTooltip( message: dialogContext.l10n.clearHistory, child: GhostButton( diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart new file mode 100644 index 00000000..6de2ce88 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart' as flutter; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; + +class CreateTaskActionButtons extends StatelessWidget { + const CreateTaskActionButtons({ + super.key, + required this.submitting, + required this.onCancel, + required this.onSubmit, + required this.cancelLabel, + required this.submitLabel, + this.cancelButtonKey, + this.submitButtonKey, + }); + + final bool submitting; + final VoidCallback onCancel; + final VoidCallback onSubmit; + final String cancelLabel; + final String submitLabel; + final Key? cancelButtonKey; + final Key? submitButtonKey; + + @override + Widget build(BuildContext context) { + return flutter.Row( + mainAxisSize: MainAxisSize.min, + children: [ + SecondaryButton( + key: cancelButtonKey, + onPressed: submitting ? null : onCancel, + child: SizedBox( + width: _actionContentWidth, + child: Center(child: Text(cancelLabel)), + ), + ), + const SizedBox(width: AppDesignTokens.space12), + AppLoadingButton( + key: submitButtonKey, + onPressed: onSubmit, + loading: submitting, + variant: AppLoadingButtonVariant.primary, + child: SizedBox( + width: _actionContentWidth, + child: Center(child: Text(submitLabel)), + ), + ), + ], + ); + } +} + +const _actionContentWidth = 68.0; 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 d5f8af8e..14eeccf7 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 @@ -189,42 +189,48 @@ class _TreeFooter extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - final filters = _FileTypeFilterGroup( - videoActive: activeTypeFilters.contains(_FileTypeFilter.video), - audioActive: activeTypeFilters.contains(_FileTypeFilter.audio), - imageActive: activeTypeFilters.contains(_FileTypeFilter.image), - onSelectVideo: onSelectVideo, - onSelectAudio: onSelectAudio, - onSelectImage: onSelectImage, + final filters = KeyedSubtree( + key: const ValueKey('resolve-tree-filters'), + child: _FileTypeFilterGroup( + videoActive: activeTypeFilters.contains(_FileTypeFilter.video), + audioActive: activeTypeFilters.contains(_FileTypeFilter.audio), + imageActive: activeTypeFilters.contains(_FileTypeFilter.image), + onSelectVideo: onSelectVideo, + onSelectAudio: onSelectAudio, + onSelectImage: onSelectImage, + ), ); - final stats = Row( - mainAxisSize: MainAxisSize.min, + final statsStyle = TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600); + final selectedLabel = context.l10n.selectedCount(selectedCount, totalCount); + final sizeLabel = unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize); + final baseline = MediaQuery.textScalerOf(context).scale(statsStyle.fontSize!); + final stats = Wrap( + key: const ValueKey('resolve-tree-selection-stats'), + alignment: WrapAlignment.end, + spacing: AppDesignTokens.space8, + runSpacing: AppDesignTokens.space4, + crossAxisAlignment: WrapCrossAlignment.start, children: [ - Text( - context.l10n.selectedCount(selectedCount, totalCount), - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + Baseline( + baseline: baseline, + baselineType: TextBaseline.alphabetic, + child: Text(key: const ValueKey('resolve-tree-selected-count'), selectedLabel, style: statsStyle), ), - const SizedBox(width: 8), - Text( - unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize), - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + Baseline( + baseline: baseline, + baselineType: TextBaseline.alphabetic, + child: Text(key: const ValueKey('resolve-tree-selected-size'), sizeLabel, style: statsStyle), ), ], ); - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 360) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align(alignment: Alignment.centerLeft, child: filters), - const SizedBox(height: 4), - Align(alignment: Alignment.centerRight, child: stats), - ], - ); - } - return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [filters, const Spacer(), stats]); - }, + + return OverflowBar( + key: const ValueKey('resolve-tree-footer'), + spacing: AppDesignTokens.space8, + alignment: MainAxisAlignment.spaceBetween, + overflowAlignment: OverflowBarAlignment.end, + overflowSpacing: AppDesignTokens.space4, + children: [filters, stats], ); } } @@ -290,7 +296,11 @@ class _FilterDivider extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox(width: 1, height: 18, child: ColoredBox(color: color)); + return SizedBox( + width: _fileTypeFilterDividerWidth, + height: 18, + child: ColoredBox(color: color), + ); } } @@ -305,7 +315,7 @@ class _FilterButton extends StatelessWidget { Widget build(BuildContext context) { final palette = AppPalette.of(context); return SizedBox( - width: 40, + width: _fileTypeFilterButtonWidth, height: 28, child: DecoratedBox( decoration: BoxDecoration(color: active ? palette.filterActiveBg : null), @@ -340,3 +350,5 @@ 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); +const _fileTypeFilterButtonWidth = 40.0; +const _fileTypeFilterDividerWidth = 1.0; 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 deleted file mode 100644 index e9b6dc37..00000000 --- a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_task_actions.dart +++ /dev/null @@ -1,50 +0,0 @@ -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 830d6eb1..3b7c407d 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count}", "resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار", "noFiles": "لا توجد ملفات", - "createHistory": "تاريخ الخلق", + "historyLinks": "روابط السجل", "noHistory": "لا تاريخ", "directDownloadDescription": "حل المشاكل وخلق المهام على الفور", "taskProgress": "التقدم المحرز", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index d9d5bcfd..4fccb3de 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -367,7 +367,7 @@ "resolvedFileCount": "{count} fayl tapıldı", "resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur", "noFiles": "Fayl yoxdur", - "createHistory": "Tarixçə Yarat", + "historyLinks": "Tarixçə keçidləri", "noHistory": "Tarixçə yoxdur", "directDownloadDescription": "Təhlili keç və tapşırıqları dərhal yarat", "taskProgress": "Tapşırığın irəliləyişi", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 1daea2e6..bb030cf5 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count}", "resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar", "noFiles": "Sense fitxers", - "createHistory": "Crea un historial", + "historyLinks": "Enllaços de l'historial", "noHistory": "Sense historial", "directDownloadDescription": "Omet la resolució i crea les tasques immediatament", "taskProgress": "Progrés de la tasca", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index fdd924ff..64d7683c 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count}-Dateien behoben", "resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste", "noFiles": "Keine Dateien", - "createHistory": "Verlauf erstellen", + "historyLinks": "Verlaufslinks", "noHistory": "Keine Historie", "directDownloadDescription": "Überspringen Sie die Lösung und erstellen Sie sofort Aufgaben", "taskProgress": "Aufgabenfortschritt", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 1a0fcfa2..5aab7de5 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -367,7 +367,7 @@ "resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}", "resourceHasNoFiles": "The resource has no selectable file list", "noFiles": "No files", - "createHistory": "Create History", + "historyLinks": "History Links", "noHistory": "No history", "directDownloadDescription": "Skip resolving and create tasks immediately", "taskProgress": "Task progress", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index c93209d1..89ed2ad0 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} resueltos", "resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable", "noFiles": "No hay archivos", - "createHistory": "Crear historia", + "historyLinks": "Enlaces del historial", "noHistory": "No hay historia", "directDownloadDescription": "Saltar a resolver y crear tareas inmediatamente", "taskProgress": "Progresos en la tarea", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 3943a61d..6486d00d 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} فایل شناسایی شد", "resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد", "noFiles": "فایل ها", - "createHistory": "ایجاد تاریخ", + "historyLinks": "پیوندهای تاریخچه", "noHistory": "تاریخ", "directDownloadDescription": "پیاده سازی و ایجاد وظایف بلافاصله", "taskProgress": "پیشرفت شغلی", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 4af83656..cd764419 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} résolus", "resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable", "noFiles": "Aucun fichier", - "createHistory": "Créer l'historique", + "historyLinks": "Liens de l'historique", "noHistory": "Pas d'histoire", "directDownloadDescription": "Sautez la résolution et créez immédiatement des tâches", "taskProgress": "Progrès accomplis dans la tâche", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 9c2fd4c8..034eff81 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} fájlok megoldva", "resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája", "noFiles": "Nincsenek fájlok", - "createHistory": "Készítsen előzményeket", + "historyLinks": "Előzményhivatkozások", "noHistory": "Nincs előzmény", "directDownloadDescription": "Hagyja ki a megoldást, és azonnal hozzon létre feladatokat", "taskProgress": "A feladat előrehaladása", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 857ec774..40acc714 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} terselesaikan", "resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih", "noFiles": "Tidak ada berkas", - "createHistory": "Buat Riwayat", + "historyLinks": "Tautan Riwayat", "noHistory": "Tidak ada sejarah", "directDownloadDescription": "Lewatkan pemecahan dan buat tugas segera", "taskProgress": "Kemajuan tugas", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 01263794..aba9620f 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} risolti", "resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile", "noFiles": "Nessun file", - "createHistory": "Creare la storia", + "historyLinks": "Link della cronologia", "noHistory": "Nessuna storia", "directDownloadDescription": "Salta la risoluzione e crea immediatamente i compiti", "taskProgress": "Prospettive di attività", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index d816b478..8f15b2d1 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} ファイルが解決しました", "resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません", "noFiles": "ファイルがない場合", - "createHistory": "歴史を創造する", + "historyLinks": "履歴リンク", "noHistory": "歴史なし", "directDownloadDescription": "解決をスキップし、すぐにタスクを作成", "taskProgress": "タスクの進捗", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index a4d3f9da..23c42a68 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} 파일 해결", "resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다", "noFiles": "파일 없음", - "createHistory": "회사연혁", + "historyLinks": "기록 링크", "noHistory": "의 역사", "directDownloadDescription": "재해를 드리며 즉시 업무를 만듭니다", "taskProgress": "작업 진행", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index 6aea60ce..85b61144 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} plików rozwiązanych", "resourceHasNoFiles": "Zasób nie ma listy plików do wyboru", "noFiles": "Brak plików", - "createHistory": "Utwórz historię", + "historyLinks": "Linki historii", "noHistory": "Brak historii", "directDownloadDescription": "Pomiń rozwiązywanie i tworzyć zadania natychmiast", "taskProgress": "Postępy w realizacji zadań", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 39b3a81a..46674d2c 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} ficheiros resolvidos", "resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis", "noFiles": "Sem arquivos", - "createHistory": "Criar Histórico", + "historyLinks": "Links do histórico", "noHistory": "Sem histórico", "directDownloadDescription": "Ignorar resolver e criar tarefas imediatamente", "taskProgress": "Progresso da tarefa", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 782a5c9e..53f5f2f6 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} файлов, разрешенных", "resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов", "noFiles": "Нет файлов", - "createHistory": "Создаем историю", + "historyLinks": "Ссылки в истории", "noHistory": "Нет истории", "directDownloadDescription": "Немедленно пропустите решение и создайте задачи", "taskProgress": "Прогресс в решении задач", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 00d06c0c..bccefc16 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன", "resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை", "noFiles": "(% 1 மொத்தம்) Items (Folders, Files), Size", - "createHistory": "வரலாற்றை உருவாக்கு", + "historyLinks": "வரலாற்று இணைப்புகள்", "noHistory": "& வரலாற்றில் கண்டுபிடி... Unlimited (number of lines)", "directDownloadDescription": "தீர்க்கப்படுவதைத் தவிர்த்து, உடனடியாகப் பணிகளை உருவாக்கவும்", "taskProgress": "பணி முன்னேற்றம்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index a753215d..9b3b4954 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} dosyaları çözüldü", "resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir", "noFiles": "Hiçbir dosya yok", - "createHistory": "Create History Create History", + "historyLinks": "Geçmiş Bağlantıları", "noHistory": "Geçmiş yok", "directDownloadDescription": "Hemen çözümle ve görevleri oluşturun", "taskProgress": "Görev ilerlemesi", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 82c6cc34..e4b3e923 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} файли вирішені", "resourceHasNoFiles": "Ресурс не має вибраного списку файлів", "noFiles": "Немає файлів", - "createHistory": "Історія", + "historyLinks": "Посилання історії", "noHistory": "Історія", "directDownloadDescription": "Вирішити і створювати завдання відразу", "taskProgress": "Завдання", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index ccd43cb7..f7a6db52 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "{count} tập tin được giải quyết", "resourceHasNoFiles": "Không thể chọn danh sách tập tin", "noFiles": "Không có tập tin", - "createHistory": "Tạo ra lịch sử", + "historyLinks": "Liên kết lịch sử", "noHistory": "Không có lịch sử", "directDownloadDescription": "Bỏ qua việc giải quyết và tạo tác vụ ngay lập tức", "taskProgress": "Tiến trình tác vụ", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index ddc6e16d..0bd33ecd 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "已解析 {count} 个文件", "resourceHasNoFiles": "该资源没有可选择的文件列表", "noFiles": "没有文件", - "createHistory": "创建历史", + "historyLinks": "历史链接", "noHistory": "暂无历史记录", "directDownloadDescription": "跳过解析并立即创建任务", "taskProgress": "任务进度", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index db5bb6a2..8220df52 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -353,7 +353,7 @@ "resolvedFileCount": "已解析 {count} 個檔案", "resourceHasNoFiles": "該資源沒有可選擇的檔案列表", "noFiles": "沒有檔案", - "createHistory": "建立歷史記錄", + "historyLinks": "歷史連結", "noHistory": "暫無歷史記錄", "directDownloadDescription": "跳過解析並立即建立任務", "taskProgress": "任務進度", 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 8b05f599..2ca8e88f 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 @@ -499,11 +499,12 @@ void main() { 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(createButton.child, isA()); + expect((createButton.child as SizedBox).width, 68); + expect( + find.descendant(of: find.byKey(const ValueKey('resolve-create-button')), matching: find.text('Create')), + findsOneWidget, + ); expect( tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))), tester.getSize(find.byKey(const ValueKey('resolve-create-button'))), @@ -555,6 +556,7 @@ void main() { await tester.tap(find.byIcon(Icons.history)); await tester.pumpAndSettle(); + expect(find.text('History Links'), findsOneWidget); expect( tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, 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 index 4a8d5940..8095bdf0 100644 --- a/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart +++ b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart @@ -1,6 +1,6 @@ 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/features/tasks/presentation/widgets/create_task_action_buttons.dart'; import 'package:gopeed/l10n/l10n.dart'; import 'package:gopeed/shared/theme/app_component_themes.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; @@ -36,10 +36,14 @@ void main() { content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100), actions: [ Flexible( - child: ResolveTaskActions( + child: CreateTaskActionButtons( submitting: submitting, onCancel: () => cancelled = true, - onCreate: () => setState(() => submitting = true), + onSubmit: () => setState(() => submitting = true), + cancelLabel: context.l10n.cancel, + submitLabel: context.l10n.createAction, + cancelButtonKey: const ValueKey('resolve-cancel-button'), + submitButtonKey: const ValueKey('resolve-create-button'), ), ), ], @@ -58,6 +62,9 @@ void main() { final createRect = tester.getRect(create); expect(cancelRect.size, createRect.size); expect(cancelRect.center.dy, createRect.center.dy); + expect(createRect.left - cancelRect.right, 12); + expect((tester.widget(cancel).child as SizedBox).width, 68); + expect((tester.widget(create).child as SizedBox).width, 68); expect(cancelRect.left, greaterThanOrEqualTo(0)); expect(createRect.right, lessThanOrEqualTo(width)); expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index b90329cc..cf6098e4 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2669,9 +2669,9 @@ void main() { padding: const EdgeInsets.all(24), child: ResolveFileTree( files: [ - FileInfo(path: 'videos', name: 'clip.mp4', size: 1024), - FileInfo(path: 'docs', name: 'notes.txt', size: 128), - FileInfo(path: 'docs', name: 'manual.pdf', size: 256), + FileInfo(path: 'videos', name: 'clip.mp4', size: 0), + FileInfo(path: 'docs', name: 'notes.txt', size: 0), + FileInfo(path: 'docs', name: 'manual.pdf', size: 0), ], initialSelection: const [0, 1, 2], onSelectionChanged: (values) => selected = values, @@ -2708,7 +2708,26 @@ void main() { expect(find.byKey(const ValueKey('resolve-tree-expand-toggle')), findsOneWidget); expect(find.byType(shad.OutlinedContainer), findsOneWidget); expect(find.text('clip.mp4'), findsOneWidget); + expect(find.text('Unknown size'), findsOneWidget); expect(tester.widget(find.text('clip.mp4')).style?.color, AppPalette.light.textPrimary); + final baselines = tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('resolve-tree-selection-stats')), + matching: find.byType(Baseline), + ), + ) + .toList(); + expect(baselines, hasLength(2)); + expect(baselines.first.baseline, baselines.last.baseline); + expect(baselines.every((widget) => widget.baselineType == TextBaseline.alphabetic), isTrue); + final filtersRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-filters'))); + final statsRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selection-stats'))); + final selectedCountRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-count'))); + final selectedSizeRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-size'))); + expect(statsRect.center.dy, closeTo(filtersRect.center.dy, 0.01)); + expect(selectedCountRect.center.dy, closeTo(selectedSizeRect.center.dy, 0.01)); + expect(selectedSizeRect.left, greaterThan(selectedCountRect.right)); await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); await tester.pumpAndSettle(); @@ -2770,8 +2789,16 @@ void main() { expect(find.text('clip.mp4'), findsOneWidget); expect(selected, [0, 1, 2]); - tester.view.physicalSize = const Size(360, 620); + tester.view.physicalSize = const Size(280, 620); await tester.pumpAndSettle(); + expect( + tester.getRect(find.byKey(const ValueKey('resolve-tree-selection-stats'))).top, + greaterThanOrEqualTo(tester.getRect(find.byKey(const ValueKey('resolve-tree-filters'))).bottom), + ); + expect( + tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-size'))).top, + greaterThanOrEqualTo(tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-count'))).bottom), + ); expect(tester.takeException(), isNull); }); From 04b9aa18eca7526a13956656f6a5d7e82f24a8eb Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Fri, 11 Sep 2026 08:01:32 +0800 Subject: [PATCH 08/12] fix(http): infer extension for opaque URLs (#1495) --- internal/protocol/http/fetcher.go | 1 + internal/protocol/http/fetcher_test.go | 24 +++++++++ internal/protocol/http/filename_parse_test.go | 49 +++++++++++++++++++ internal/protocol/http/helper.go | 23 +++++++++ 4 files changed, 97 insertions(+) diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index cb168cf3..6dfd9855 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -459,6 +459,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { if file.Name == "" || file.Name == "/" || file.Name == "." { file.Name = httpReq.URL.Hostname() } + file.Name = appendFilenameExtension(file.Name, resp.Header.Get("Content-Type")) res.Files = append(res.Files, file) f.meta.Res = res diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 7f07bedb..16d8403c 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -221,6 +221,30 @@ func TestFetcher_Resolve(t *testing.T) { }) } +func TestFetcher_ResolveFilenameFromContentType(t *testing.T) { + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(gohttp.StatusOK) + })) + defer server.Close() + + fetcher := buildFetcher() + defer fetcher.Pause() + + const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP" + err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + }) + if err != nil { + t.Fatal(err) + } + + if got := fetcher.meta.Res.Files[0].Name; got != opaqueName+".webp" { + t.Fatalf("resolved filename = %q, want %q", got, opaqueName+".webp") + } +} + func TestFetcher_ResolveWithHostHeader(t *testing.T) { listener := test.StartTestHostHeaderServer() defer listener.Close() diff --git a/internal/protocol/http/filename_parse_test.go b/internal/protocol/http/filename_parse_test.go index 7ce406f5..6945250f 100644 --- a/internal/protocol/http/filename_parse_test.go +++ b/internal/protocol/http/filename_parse_test.go @@ -136,3 +136,52 @@ func TestUnescapeHTMLEntities(t *testing.T) { }) } } + +func TestAppendFilenameExtension(t *testing.T) { + tests := []struct { + name string + filename string + contentType string + want string + }{ + { + name: "adds extension from content type", + filename: "download", + contentType: "image/png", + want: "download.png", + }, + { + name: "parses MIME parameters", + filename: "download", + contentType: "image/png; charset=binary", + want: "download.png", + }, + { + name: "preserves existing extension", + filename: "download.zip", + contentType: "application/zip", + want: "download.zip", + }, + { + name: "ignores invalid content type", + filename: "download", + contentType: "not a content type", + want: "download", + }, + { + name: "ignores unknown content type", + filename: "download", + contentType: "application/x-gopeed-unknown", + want: "download", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendFilenameExtension(tt.filename, tt.contentType) + if got != tt.want { + t.Errorf("appendFilenameExtension() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/protocol/http/helper.go b/internal/protocol/http/helper.go index 1292723e..30a27bac 100644 --- a/internal/protocol/http/helper.go +++ b/internal/protocol/http/helper.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "path" "strings" "time" "unicode/utf8" @@ -314,6 +315,28 @@ func parseFilename(contentDisposition string) string { return parseFilenameFallback(contentDisposition) } +// appendFilenameExtension adds a MIME-derived extension when the resolved +// filename has no usable extension. Some image hosts use opaque path segments +// that look like extensions, even though the response Content-Type is the +// only reliable source for the file type. +func appendFilenameExtension(filename, contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return filename + } + + extensions, err := mime.ExtensionsByType(mediaType) + if err != nil || len(extensions) == 0 { + return filename + } + + ext := path.Ext(filename) + if len(ext) > 1 && len(ext) <= 20 { + return filename + } + return filename + extensions[0] +} + // parseFilenameExtended handles RFC 5987 extended notation (filename*=) // Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip) func parseFilenameExtended(cd string) string { From 073e1413abb7e67266805c9b6741d4d2639a4a83 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 11 Sep 2026 08:20:45 +0800 Subject: [PATCH 09/12] release: 2.0.0-beta.2 --- ui/flutter/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index bd3d6b36..cb27bf83 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.0-beta.1+1 +version: 2.0.0-beta.2+2 environment: sdk: ^3.11.4 From e5ac13cfdcbed2f7409e06ff64c6fd81e6739d7b Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 11 Sep 2026 08:36:01 +0800 Subject: [PATCH 10/12] docs: update beta download links --- README.md | 2 +- README_ja-JP.md | 2 +- README_vi-VN.md | 2 +- README_zh-CN.md | 2 +- README_zh-TW.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b74a1c76..a5f84853 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter. -- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel. diff --git a/README_ja-JP.md b/README_ja-JP.md index 30fb6082..8e0034f2 100644 --- a/README_ja-JP.md +++ b/README_ja-JP.md @@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管 Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。 -- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) 機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。 diff --git a/README_vi-VN.md b/README_vi-VN.md index f508f657..97af5bcf 100644 --- a/README_vi-VN.md +++ b/README_vi-VN.md @@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề. -- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta. diff --git a/README_zh-CN.md b/README_zh-CN.md index 2f70a3ce..46446941 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。 -- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) 当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。 diff --git a/README_zh-TW.md b/README_zh-TW.md index 6b981653..0a05b7a9 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、 Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。 -- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) 當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。 From ff8f61423e30923d46f9e7122404a8d0715888f4 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 07:25:37 +0800 Subject: [PATCH 11/12] fix(ui): refine app update dialog layout and scrolling (#1503) --- .../widgets/app_update_dialog.dart | 28 ++++----- .../widgets/app_update_notes_viewport.dart | 59 +++++++++++++++++++ ui/flutter/test/updater_test.dart | 39 ++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart index 7367c609..35a29293 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart @@ -10,6 +10,7 @@ import '../../../../util/package_info.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../../l10n/l10n.dart'; +import 'app_update_notes_viewport.dart'; typedef StartAppUpdate = Future Function(VersionInfo versionInfo, UpdateProgressCallback onProgress); @@ -74,11 +75,18 @@ Future showAppUpdateDialog( key: const ValueKey('app-update-heading'), textAlign: TextAlign.center, ), - const SizedBox(height: 3), + const SizedBox(height: 8), Text( '$appVersion → ${versionInfo.version}', textAlign: TextAlign.center, - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400), + style: DefaultTextStyle.of(dialogContext).style.copyWith( + inherit: false, + color: palette.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.4, + decoration: TextDecoration.none, + ), ), ], ), @@ -90,18 +98,9 @@ Future showAppUpdateDialog( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - dialogContext.l10n.releaseNotes, - style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 210), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _ReleaseNotes( - notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, - ), + AppUpdateNotesViewport( + child: _ReleaseNotes( + notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, ), ), if (updating) ...[ @@ -157,7 +156,6 @@ Future showAppUpdateDialog( onPressed: () => unawaited(update()), loading: updating, variant: AppLoadingButtonVariant.primary, - icon: const Icon(Icons.system_update_alt_outlined, size: 17), child: Text(dialogContext.l10n.newVersionUpdate), ), ], diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart new file mode 100644 index 00000000..51dbe968 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; + +class AppUpdateNotesViewport extends StatefulWidget { + const AppUpdateNotesViewport({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AppUpdateNotesViewportState(); +} + +class _AppUpdateNotesViewportState extends State { + final _controller = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const fadeHeight = AppDesignTokens.space24; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: shad.Scrollbar( + controller: _controller, + thumbVisibility: true, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // Mask colors control opacity only; they do not tint the content. + colors: const [shad.Colors.white, shad.Colors.white, shad.Colors.transparent], + stops: [0, (1 - fadeHeight / bounds.height).clamp(0.0, 1.0), 1], + ).createShader(bounds), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: _controller, + padding: const EdgeInsetsDirectional.fromSTEB( + AppDesignTokens.space4, + 0, + AppDesignTokens.space16, + // Keep the final line above the fade at the end of the scroll. + fadeHeight, + ), + child: widget.child, + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/test/updater_test.dart b/ui/flutter/test/updater_test.dart index 6ec81862..6ea25fad 100644 --- a/ui/flutter/test/updater_test.dart +++ b/ui/flutter/test/updater_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart'; +import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/util/arch/arch.dart'; import 'package:gopeed/util/github_mirror.dart'; @@ -193,10 +195,47 @@ void main() { expect(find.text('下载更快'), findsNothing); expect(find.text('Later'), findsOneWidget); expect(find.text('Update Now'), findsOneWidget); + expect(find.text("What's New"), findsNothing); + final versionText = tester.widget(find.text('2.0.0-beta.1 → 2.0.0-beta.2')); + expect(versionText.style?.decoration, TextDecoration.none); + expect(versionText.style?.inherit, isFalse); + expect(find.byIcon(Icons.system_update_alt_outlined), findsNothing); final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog'))); expect(dialog.width, lessThan(320)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.takeException(), isNull); }); + + testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: Center( + child: SizedBox( + width: 280, + child: AppUpdateNotesViewport( + child: Column(children: [for (var i = 0; i < 30; i++) Text('Release note $i')]), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scrollbar = tester.widget(find.byType(shad.Scrollbar)); + expect(scrollbar.thumbVisibility, isTrue); + final controller = scrollbar.controller!; + expect(controller.position.maxScrollExtent, greaterThan(0)); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + final viewport = tester.getRect(find.byType(AppUpdateNotesViewport)); + final lastLine = tester.getRect(find.text('Release note 29')); + expect(lastLine.bottom, lessThanOrEqualTo(viewport.bottom - 24)); + expect(lastLine.top, greaterThan(viewport.top)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); } From 091e7b8936d738544abefead2d411fdc845ed060 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 09:28:04 +0800 Subject: [PATCH 12/12] fix(ui): keep mobile create task actions in safe area (#1504) --- .../pages/create_task_window_page.dart | 39 ++++++++++--------- .../create_task_window_page_test.dart | 6 ++- 2 files changed, 26 insertions(+), 19 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 55fb3ad1..0e242ec8 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 @@ -687,24 +687,27 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), ), - Container( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CreateTaskActionButtons( - submitting: _creating, - onCancel: _closeWindow, - onSubmit: _confirm, - cancelLabel: context.l10n.cancel, - submitLabel: context.l10n.confirm, - cancelButtonKey: const ValueKey('create-task-cancel-button'), - submitButtonKey: const ValueKey('create-task-confirm-button'), - ), - ], + SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.border)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CreateTaskActionButtons( + submitting: _creating, + onCancel: _closeWindow, + onSubmit: _confirm, + cancelLabel: context.l10n.cancel, + submitLabel: context.l10n.confirm, + cancelButtonKey: const ValueKey('create-task-cancel-button'), + submitButtonKey: const ValueKey('create-task-confirm-button'), + ), + ], + ), ), ), ], 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 2ca8e88f..c9114bcc 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 @@ -165,7 +165,7 @@ void main() { } }); - testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async { + testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); tester.view.devicePixelRatio = 1; @@ -193,6 +193,10 @@ void main() { final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); expect((contentPadding.padding as EdgeInsets).top, 32); + expect( + tester.getBottomRight(find.byKey(const ValueKey('create-task-confirm-button'))).dy, + lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio - 24), + ); } finally { debugDefaultTargetPlatformOverride = null; }