merge: PR #1488 create task mobile UI

This commit is contained in:
Levi committed 2026-09-08 20:23:52 +08:00
commit 5728caf7db
31 files changed
+253 -93

No files matched your search

@@ -29,9 +29,9 @@ import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart';
import '../../../../shared/widgets/app_http_headers_editor.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_number_input.dart';
import '../../../../shared/widgets/app_path_picker_field.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_text_field.dart';
import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
@@ -87,6 +87,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
String _configuredDownloadDirectory = ''; String _configuredDownloadDirectory = '';
String _fileDataUri = ''; String _fileDataUri = '';
bool _programmaticUrlChange = false; bool _programmaticUrlChange = false;
String _lastUrlText = '';
@override @override
void initState() { void initState() {
@@ -162,10 +163,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} }
void _handleUrlChanged() { void _handleUrlChanged() {
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { final urlText = _urlController.text;
_fileDataUri = ''; if (urlText != _lastUrlText) {
_lastUrlText = urlText;
_renameController.clear();
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
_fileDataUri = '';
}
} }
_recognizeMagnetUri(_urlController.text.trim()); _recognizeMagnetUri(urlText.trim());
} }
void _applyInitialTask(CreateTask? task) { void _applyInitialTask(CreateTask? task) {
@@ -203,9 +209,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} }
} }
if (opts != null) { if (opts != null) {
if (opts.name.isNotEmpty) {
_renameController.text = opts.name;
}
if (opts.path.isNotEmpty) { if (opts.path.isNotEmpty) {
_directoryController.text = opts.path; _directoryController.text = opts.path;
} }
@@ -307,6 +310,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
SizedBox( SizedBox(
height: 120, height: 120,
child: AppTextField( child: AppTextField(
key: const ValueKey('create-task-url-input'),
controller: _urlController, controller: _urlController,
hintText: context.l10n.pasteDownloadLinks, hintText: context.l10n.pasteDownloadLinks,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
@@ -352,18 +356,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
_FormRow( _FormRow(
label: context.l10n.connections, label: context.l10n.connections,
child: Align( child: AppNumberInput(
alignment: Alignment.centerLeft, fieldKey: const ValueKey('create-task-connections-input'),
child: SizedBox( controller: _connectionsController,
width: AppDesignTokens.settingsNumberControlWidth, min: 1,
child: AppNumberInput( max: 256,
fieldKey: const ValueKey('create-task-connections-input'), hintText: context.l10n.enterCount,
controller: _connectionsController,
min: 1,
max: 256,
hintText: context.l10n.enterCount,
),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -378,10 +376,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
pickerKey: const ValueKey('create-task-directory-picker'), pickerKey: const ValueKey('create-task-directory-picker'),
controller: _directoryController, controller: _directoryController,
hintText: context.l10n.chooseDownloadDirectory, 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: (_) { onChanged: (_) {
if (_asDefaultPath) { if (_asDefaultPath) {
setState(() => _asDefaultPath = false); setState(() => _asDefaultPath = false);
@@ -689,16 +683,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))), child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppPrimaryButton( AppLoadingButton(
onPressed: _creating ? null : _confirm, key: const ValueKey('create-task-confirm-button'),
child: SizedBox( onPressed: _confirm,
width: 68, loading: _creating,
child: Center( variant: AppLoadingButtonVariant.primary,
child: _creating child: SizedBox(width: 68, child: Center(child: Text(context.l10n.confirm))),
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
: Text(context.l10n.confirm),
),
),
), ),
], ],
), ),
@@ -866,7 +856,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final options = _buildOptions(); final options = _buildOptions();
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options)); final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options));
if (!mounted) return; if (!mounted) return;
_syncResolvedName(result);
final created = await _showResolveDialog(request, result); final created = await _showResolveDialog(request, result);
if (!created) { if (!created) {
if (mounted) { if (mounted) {
@@ -1155,16 +1144,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
onPressed: submitting ? null : () => closeOverlay(dialogContext, false), onPressed: submitting ? null : () => closeOverlay(dialogContext, false),
child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))), child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))),
), ),
AppPrimaryButton( AppLoadingButton(
onPressed: submitting ? null : submit, key: const ValueKey('resolve-create-button'),
child: SizedBox( onPressed: submit,
width: 68, loading: submitting,
child: Center( variant: AppLoadingButtonVariant.primary,
child: submitting child: Text(dialogContext.l10n.createAction, maxLines: 1, softWrap: false),
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
: Text(dialogContext.l10n.create),
),
),
), ),
], ],
); );
@@ -1326,12 +1311,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
filterController.dispose(); 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 = ''}) { void _setUrlText(String value, {String fileDataUri = ''}) {
_programmaticUrlChange = true; _programmaticUrlChange = true;
_urlController.text = value; _urlController.text = value;
@@ -1673,17 +1652,12 @@ class _WindowTextField extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return AppTextField( return AppTextField(
controller: controller, controller: controller,
hintText: hintText, hintText: hintText,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
obscureText: obscureText, obscureText: obscureText,
filled: true,
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
); );
} }
} }
@@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
child: FileTreeView<int>( child: FileTreeView<int>(
items: _treeItems, items: _treeItems,
keyPrefix: 'resolve-tree', keyPrefix: 'resolve-tree',
rowHeight: _resolveTreeRowHeight,
contentTextStyle: _resolveFileNameStyle,
headerLeading: ExcludeFocus( headerLeading: ExcludeFocus(
child: Checkbox( child: Checkbox(
state: allSelected state: allSelected
@@ -336,3 +338,5 @@ extension on _FileTypeFilter {
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'}; const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'}; const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'}; const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
const _resolveTreeRowHeight = 36.0;
const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25);
+1
View File
@@ -18,6 +18,7 @@
"exit": "خروج", "exit": "خروج",
"pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج", "pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج",
"create": "إنشاء مهمة", "create": "إنشاء مهمة",
"createAction": "إنشاء",
"directDownload": "تنزيل مباشر", "directDownload": "تنزيل مباشر",
"advancedOptions": "خيارات متقدمة", "advancedOptions": "خيارات متقدمة",
"followSettings": "اتباع الإعدادات", "followSettings": "اتباع الإعدادات",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Surt", "exit": "Surt",
"pressBackAgainToExit": "Torna a prémer enrere per sortir", "pressBackAgainToExit": "Torna a prémer enrere per sortir",
"create": "Crea una tasca", "create": "Crea una tasca",
"createAction": "Crea",
"directDownload": "Baixada directa", "directDownload": "Baixada directa",
"advancedOptions": "Opcions avançades", "advancedOptions": "Opcions avançades",
"followSettings": "Segueix la configuració", "followSettings": "Segueix la configuració",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Beenden", "exit": "Beenden",
"pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden", "pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden",
"create": "Aufgabe Erstellen", "create": "Aufgabe Erstellen",
"createAction": "Erstellen",
"directDownload": "Direkter Download", "directDownload": "Direkter Download",
"advancedOptions": "Erweiterte Einstellungen", "advancedOptions": "Erweiterte Einstellungen",
"followSettings": "Einstellungen Folgen", "followSettings": "Einstellungen Folgen",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Exit", "exit": "Exit",
"pressBackAgainToExit": "Press back again to exit", "pressBackAgainToExit": "Press back again to exit",
"create": "Create Task", "create": "Create Task",
"createAction": "Create",
"directDownload": "Direct Download", "directDownload": "Direct Download",
"advancedOptions": "Advanced Options", "advancedOptions": "Advanced Options",
"followSettings": "Follow Settings", "followSettings": "Follow Settings",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Salir", "exit": "Salir",
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir", "pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
"create": "Crear Tarea", "create": "Crear Tarea",
"createAction": "Crear",
"directDownload": "Descarga Directa", "directDownload": "Descarga Directa",
"advancedOptions": "Opciones Avanzadas", "advancedOptions": "Opciones Avanzadas",
"followSettings": "Siga las opciones", "followSettings": "Siga las opciones",
+1
View File
@@ -18,6 +18,7 @@
"exit": "خروج", "exit": "خروج",
"pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید", "pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید",
"create": "ایجاد کار", "create": "ایجاد کار",
"createAction": "ایجاد",
"directDownload": "دانلود مستقیم", "directDownload": "دانلود مستقیم",
"advancedOptions": "گزینه های پیشرفته", "advancedOptions": "گزینه های پیشرفته",
"followSettings": "تنظیمات را دنبال کنید", "followSettings": "تنظیمات را دنبال کنید",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Quitter", "exit": "Quitter",
"pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter", "pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter",
"create": "Créer une tâche", "create": "Créer une tâche",
"createAction": "Créer",
"directDownload": "Téléchargement direct", "directDownload": "Téléchargement direct",
"advancedOptions": "Options avancées", "advancedOptions": "Options avancées",
"followSettings": "Suivre les paramètres", "followSettings": "Suivre les paramètres",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Kilépés", "exit": "Kilépés",
"pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez", "pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez",
"create": "Feladat létrehozása", "create": "Feladat létrehozása",
"createAction": "Létrehozás",
"directDownload": "Közvetlen letöltés", "directDownload": "Közvetlen letöltés",
"advancedOptions": "Haladó beállítások", "advancedOptions": "Haladó beállítások",
"followSettings": "Beállítások követése", "followSettings": "Beállítások követése",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Keluar", "exit": "Keluar",
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
"create": "Membuat Tugas", "create": "Membuat Tugas",
"createAction": "Buat",
"directDownload": "Unduhan Langsung", "directDownload": "Unduhan Langsung",
"advancedOptions": "Pengaturan Lanjutan", "advancedOptions": "Pengaturan Lanjutan",
"followSettings": "Mengikuti Pengaturan", "followSettings": "Mengikuti Pengaturan",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Uscita", "exit": "Uscita",
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire", "pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
"create": "Crea attività", "create": "Crea attività",
"createAction": "Crea",
"directDownload": "Scaricamento diretto", "directDownload": "Scaricamento diretto",
"advancedOptions": "Opzioni avanzate", "advancedOptions": "Opzioni avanzate",
"followSettings": "Seguire le impostazioni", "followSettings": "Seguire le impostazioni",
+1
View File
@@ -18,6 +18,7 @@
"exit": "終了", "exit": "終了",
"pressBackAgainToExit": "もう一度戻るボタンを押すと終了します", "pressBackAgainToExit": "もう一度戻るボタンを押すと終了します",
"create": "タスクを作成", "create": "タスクを作成",
"createAction": "作成",
"directDownload": "直接ダウンロード", "directDownload": "直接ダウンロード",
"advancedOptions": "詳細オプション", "advancedOptions": "詳細オプション",
"followSettings": "設定に従う", "followSettings": "設定に従う",
+1
View File
@@ -18,6 +18,7 @@
"exit": "종료", "exit": "종료",
"pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요", "pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요",
"create": "작업 생성", "create": "작업 생성",
"createAction": "생성",
"directDownload": "바로 다운로드", "directDownload": "바로 다운로드",
"advancedOptions": "고급 옵션", "advancedOptions": "고급 옵션",
"followSettings": "설정값 사용", "followSettings": "설정값 사용",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Wyjście", "exit": "Wyjście",
"pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść", "pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść",
"create": "Utwórz zadanie", "create": "Utwórz zadanie",
"createAction": "Utwórz",
"directDownload": "Pobieranie bezpośrednie", "directDownload": "Pobieranie bezpośrednie",
"advancedOptions": "Zaawansowane", "advancedOptions": "Zaawansowane",
"followSettings": "Podążaj za ustawieniami", "followSettings": "Podążaj za ustawieniami",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Sair", "exit": "Sair",
"pressBackAgainToExit": "Prima novamente Voltar para sair", "pressBackAgainToExit": "Prima novamente Voltar para sair",
"create": "Criar Tarefa", "create": "Criar Tarefa",
"createAction": "Criar",
"directDownload": "Download Direto", "directDownload": "Download Direto",
"advancedOptions": "Opções Avançadas", "advancedOptions": "Opções Avançadas",
"followSettings": "Seguir as Configurações", "followSettings": "Seguir as Configurações",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Выход", "exit": "Выход",
"pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти", "pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти",
"create": "Создать задачу", "create": "Создать задачу",
"createAction": "Создать",
"directDownload": "Прямая загрузка", "directDownload": "Прямая загрузка",
"advancedOptions": "Расширенные опции", "advancedOptions": "Расширенные опции",
"followSettings": "По умолчанию", "followSettings": "По умолчанию",
+1
View File
@@ -18,6 +18,7 @@
"exit": "வெளியேறு", "exit": "வெளியேறு",
"pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்", "pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்",
"create": "பணியை உருவாக்கு", "create": "பணியை உருவாக்கு",
"createAction": "உருவாக்கு",
"directDownload": "நேரடி பதிவிறக்கம்", "directDownload": "நேரடி பதிவிறக்கம்",
"advancedOptions": "மேம்பட்ட விருப்பங்கள்", "advancedOptions": "மேம்பட்ட விருப்பங்கள்",
"followSettings": "அமைப்புகளைப் பின்தொடரவும்", "followSettings": "அமைப்புகளைப் பின்தொடரவும்",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Çıkış", "exit": "Çıkış",
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın", "pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın",
"create": "İşlem Oluştur", "create": "İşlem Oluştur",
"createAction": "Oluştur",
"directDownload": "Direkt İndir", "directDownload": "Direkt İndir",
"advancedOptions": "Gelişmiş Seçenekler", "advancedOptions": "Gelişmiş Seçenekler",
"followSettings": "Ayarları Takip Et", "followSettings": "Ayarları Takip Et",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Вихід", "exit": "Вихід",
"pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти", "pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти",
"create": "Створити завдання", "create": "Створити завдання",
"createAction": "Створити",
"directDownload": "Пряме завантаження", "directDownload": "Пряме завантаження",
"advancedOptions": "Додаткові параметри", "advancedOptions": "Додаткові параметри",
"followSettings": "За замовчуванням", "followSettings": "За замовчуванням",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Thoát", "exit": "Thoát",
"pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát", "pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát",
"create": "Tạo nhiệm vụ", "create": "Tạo nhiệm vụ",
"createAction": "Tạo",
"directDownload": "Tải trực tiếp", "directDownload": "Tải trực tiếp",
"advancedOptions": "Tùy chọn nâng cao", "advancedOptions": "Tùy chọn nâng cao",
"followSettings": "Theo thiết lập", "followSettings": "Theo thiết lập",
+1
View File
@@ -18,6 +18,7 @@
"exit": "退出", "exit": "退出",
"pressBackAgainToExit": "再按一次返回键退出应用", "pressBackAgainToExit": "再按一次返回键退出应用",
"create": "创建任务", "create": "创建任务",
"createAction": "创建",
"directDownload": "直接下载", "directDownload": "直接下载",
"advancedOptions": "高级选项", "advancedOptions": "高级选项",
"followSettings": "跟随设置", "followSettings": "跟随设置",
+1
View File
@@ -18,6 +18,7 @@
"exit": "退出", "exit": "退出",
"pressBackAgainToExit": "再按一次返回鍵退出應用程式", "pressBackAgainToExit": "再按一次返回鍵退出應用程式",
"create": "建立任務", "create": "建立任務",
"createAction": "建立",
"directDownload": "直接下載", "directDownload": "直接下載",
"advancedOptions": "進階選項", "advancedOptions": "進階選項",
"followSettings": "跟隨設定", "followSettings": "跟隨設定",
@@ -18,42 +18,50 @@ class AppComponentThemes extends StatelessWidget {
align: 1, align: 1,
border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1), border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1),
), ),
child: shad.ComponentTheme<shad.SecondaryButtonTheme>( child: shad.ComponentTheme<shad.TextFieldTheme>(
data: shad.SecondaryButtonTheme( data: shad.TextFieldTheme(
decoration: (context, states, value) { filled: true,
final decoration = switch (value) { border: Border.all(color: palette.border),
final BoxDecoration decoration => decoration, borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
_ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), padding: AppDesignTokens.textFieldPadding,
};
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<shad.CheckboxTheme>( child: shad.ComponentTheme<shad.SecondaryButtonTheme>(
data: shad.CheckboxTheme( data: shad.SecondaryButtonTheme(
size: AppDesignTokens.checkboxSize, decoration: (context, states, value) {
gap: AppDesignTokens.checkboxLabelGap, final decoration = switch (value) {
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, 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<shad.ToastTheme>( child: shad.ComponentTheme<shad.CheckboxTheme>(
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), data: shad.CheckboxTheme(
child: child, size: AppDesignTokens.checkboxSize,
gap: AppDesignTokens.checkboxLabelGap,
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border,
),
child: shad.ComponentTheme<shad.ToastTheme>(
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)),
child: child,
),
), ),
), ),
), ),
@@ -21,6 +21,8 @@ class AppDesignTokens {
static const double settingsFormControlWidth = 420; static const double settingsFormControlWidth = 420;
static const double settingsNumberControlWidth = 168; static const double settingsNumberControlWidth = 168;
static const double settingsItemMinLabelWidth = 160; static const double settingsItemMinLabelWidth = 160;
static const EdgeInsets textFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 11);
static const EdgeInsets numberTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 10);
static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32); static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32);
static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16); static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16);
@@ -30,7 +30,15 @@ class AppLoadingButton extends StatelessWidget {
final palette = AppPalette.of(context); final palette = AppPalette.of(context);
final replaceChild = loading && icon == null; final replaceChild = loading && icon == null;
final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon; 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 final content = leading == null
? effectiveChild ? effectiveChild
: Row( : Row(
@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../theme/app_design_tokens.dart';
import 'app_text_field.dart'; import 'app_text_field.dart';
/// A bounded numeric text field with the app's standard spinner controls. /// A bounded numeric text field with the app's standard spinner controls.
@@ -91,6 +92,7 @@ class _AppNumberInputState extends State<AppNumberInput> {
: TextInputType.number, : TextInputType.number,
inputFormatters: formatters, inputFormatters: formatters,
enabled: widget.enabled, enabled: widget.enabled,
padding: AppDesignTokens.numberTextFieldPadding,
features: [ features: [
shad.InputFeature.spinner( shad.InputFeature.spinner(
step: widget.step, step: widget.step,
@@ -11,6 +11,8 @@ import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.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_capabilities.dart';
import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart'; import 'package:gopeed/core/capabilities/capability_rpc.dart';
@@ -21,6 +23,7 @@ import 'package:gopeed/shared/services/download_directory_picker.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart'; import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_design_tokens.dart'; import 'package:gopeed/shared/theme/app_design_tokens.dart';
import 'package:gopeed/shared/theme/app_theme.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:gopeed/shared/widgets/app_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -210,7 +213,10 @@ void main() {
final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); final connectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
expect(connectionsInput, findsOneWidget); expect(connectionsInput, findsOneWidget);
expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth); expect(
tester.getSize(connectionsInput).width,
tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width,
);
final connectionsField = tester.widget<shad.TextField>(connectionsInput); final connectionsField = tester.widget<shad.TextField>(connectionsInput);
expect(connectionsField.controller!.text, '12'); expect(connectionsField.controller!.text, '12');
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>()); expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
@@ -367,6 +373,56 @@ void main() {
expect(options.archivePassword, 'archive-password'); expect(options.archivePassword, 'archive-password');
expect(options.deleteAfterExtract, isTrue); expect(options.deleteAfterExtract, isTrue);
expect(submitted?.opts?.asDefaultPath, isTrue); expect(submitted?.opts?.asDefaultPath, isTrue);
expect(submitted?.opts?.name, isEmpty);
});
testWidgets('resolved task keeps rename empty and uses a single-line intrinsic create action', (
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved-id',
res: Resource(
name: 'server-generated-name',
files: [FileInfo(name: 'video.mp4', size: 1024)],
),
),
)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(_fieldText(tester, 'create-task-rename-input'), isEmpty);
final createButton = tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button')));
expect(createButton.child, isA<Text>());
final createText = createButton.child as Text;
expect(createText.data, 'Create');
expect(createText.maxLines, 1);
expect(createText.softWrap, isFalse);
expect(tester.takeException(), isNull);
}); });
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
@@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() { 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<shad.TextFieldTheme>(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 { testWidgets('provides the standard checkbox metrics', (tester) async {
late shad.CheckboxTheme checkboxTheme; late shad.CheckboxTheme checkboxTheme;
@@ -134,7 +134,10 @@ void main() {
of: find.byKey(const ValueKey('text-only-loading-button')), of: find.byKey(const ValueKey('text-only-loading-button')),
matching: find.byType(shad.CircularProgressIndicator), matching: find.byType(shad.CircularProgressIndicator),
); );
expect(find.text('Login'), findsNothing); final hiddenLabel = find.text('Login');
expect(hiddenLabel, findsOneWidget);
expect(tester.widget<Opacity>(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0);
expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget);
expect(loader, findsOneWidget); expect(loader, findsOneWidget);
expect(tester.getRect(loader).center, bounds.center); expect(tester.getRect(loader).center, bounds.center);
}); });
+59 -1
View File
@@ -1895,6 +1895,40 @@ void main() {
expect(tester.takeException(), isNull); 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 { testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900)); await _setTestSize(tester, const Size(1024, 900));
final runtimeController = FailingTrackerRuntimeController(); final runtimeController = FailingTrackerRuntimeController();
@@ -2337,7 +2371,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input')); final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input'));
final createRenameInput = find.byKey(const ValueKey('create-task-rename-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(createDirectoryInput).height, tester.getSize(createRenameInput).height);
expect(tester.getSize(createConnectionsInput), tester.getSize(createRenameInput));
final createDirectoryField = tester.widget<shad.TextField>(createDirectoryInput); final createDirectoryField = tester.widget<shad.TextField>(createDirectoryInput);
final createRenameField = tester.widget<shad.TextField>( final createRenameField = tester.widget<shad.TextField>(
find.descendant(of: createRenameInput, matching: find.byType(AppTextField)), find.descendant(of: createRenameInput, matching: find.byType(AppTextField)),
@@ -2439,6 +2475,27 @@ void main() {
expect(tester.takeException(), isNull); 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<AppTextField>(
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 { testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async {
await _setTestSize(tester, const Size(700, 500)); await _setTestSize(tester, const Size(700, 500));
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
@@ -2626,6 +2683,7 @@ void main() {
expect(treeFinder, findsOneWidget); expect(treeFinder, findsOneWidget);
final tree = tester.widget<VirtualTreeView<dynamic>>(treeFinder); final tree = tester.widget<VirtualTreeView<dynamic>>(treeFinder);
expect(tree.branchLine, same(shad.BranchLine.path)); expect(tree.branchLine, same(shad.BranchLine.path));
expect(tree.rowHeight, 36);
expect( expect(
tester.widgetList<shad.Checkbox>(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null), tester.widgetList<shad.Checkbox>(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null),
isTrue, isTrue,
@@ -4854,7 +4912,7 @@ class _CreateTaskPageHarness extends StatelessWidget {
return shad.ShadcnApp( return shad.ShadcnApp(
theme: AppTheme.light(), theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(), materialTheme: AppTheme.materialLight(),
home: const CreateTaskWindowPage(), home: const AppComponentThemes(child: CreateTaskWindowPage()),
); );
} }
} }