fix: create task panel mobile UI (#1488)

This commit is contained in:
Levi authored and GitHub committed 2026-09-10 23:02:53 +08:00
1 parent b991482617
commit 0cc2b690f8
36 files changed
+869 -827

No files matched your search

+12 -9
View File
@@ -6,15 +6,19 @@
// What it checks:
// - Every ARB file uses the repository's canonical two-space JSON formatting.
// - Every app_<locale>.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<String> _placeholders(String message) => RegExp(
/// Compares sets without depending on iteration order.
bool _sameSet(Set<String> left, Set<String> right) =>
left.length == right.length && left.containsAll(right);
void _warning(String message) => stderr.writeln('warning: $message');
@@ -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,
@@ -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<CreateTaskWindowPage> {
String _configuredDownloadDirectory = '';
String _fileDataUri = '';
bool _programmaticUrlChange = false;
String _lastUrlText = '';
@override
void initState() {
@@ -172,10 +174,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
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<CreateTaskWindowPage> {
}
}
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
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),
);
}
}
@@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
child: FileTreeView<int>(
items: _treeItems,
keyPrefix: 'resolve-tree',
rowHeight: _resolveTreeRowHeight,
contentTextStyle: _resolveFileNameStyle,
headerLeading: ExcludeFocus(
child: Checkbox(
state: allSelected
@@ -336,3 +338,5 @@ extension on _FileTypeFilter {
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
const _resolveTreeRowHeight = 36.0;
const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25);
@@ -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),
),
),
),
],
),
);
}
}
+1
View File
@@ -18,6 +18,7 @@
"exit": "خروج",
"pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج",
"create": "إنشاء مهمة",
"createAction": "إنشاء",
"directDownload": "تنزيل مباشر",
"advancedOptions": "خيارات متقدمة",
"followSettings": "اتباع الإعدادات",
+1
View File
@@ -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",
+1
View File
@@ -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ó",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -18,6 +18,7 @@
"exit": "خروج",
"pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید",
"create": "ایجاد کار",
"createAction": "ایجاد",
"directDownload": "دانلود مستقیم",
"advancedOptions": "گزینه های پیشرفته",
"followSettings": "تنظیمات را دنبال کنید",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -18,6 +18,7 @@
"exit": "終了",
"pressBackAgainToExit": "もう一度戻るボタンを押すと終了します",
"create": "タスクを作成",
"createAction": "作成",
"directDownload": "直接ダウンロード",
"advancedOptions": "詳細オプション",
"followSettings": "設定に従う",
+1
View File
@@ -18,6 +18,7 @@
"exit": "종료",
"pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요",
"create": "작업 생성",
"createAction": "생성",
"directDownload": "바로 다운로드",
"advancedOptions": "고급 옵션",
"followSettings": "설정값 사용",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Выход",
"pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти",
"create": "Создать задачу",
"createAction": "Создать",
"directDownload": "Прямая загрузка",
"advancedOptions": "Расширенные опции",
"followSettings": "По умолчанию",
+1
View File
@@ -18,6 +18,7 @@
"exit": "வெளியேறு",
"pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்",
"create": "பணியை உருவாக்கு",
"createAction": "உருவாக்கு",
"directDownload": "நேரடி பதிவிறக்கம்",
"advancedOptions": "மேம்பட்ட விருப்பங்கள்",
"followSettings": "அமைப்புகளைப் பின்தொடரவும்",
+1
View File
@@ -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",
+1
View File
@@ -18,6 +18,7 @@
"exit": "Вихід",
"pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти",
"create": "Створити завдання",
"createAction": "Створити",
"directDownload": "Пряме завантаження",
"advancedOptions": "Додаткові параметри",
"followSettings": "За замовчуванням",
+1
View File
@@ -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",
+1
View File
@@ -18,6 +18,7 @@
"exit": "退出",
"pressBackAgainToExit": "再按一次返回键退出应用",
"create": "创建任务",
"createAction": "创建",
"directDownload": "直接下载",
"advancedOptions": "高级选项",
"followSettings": "跟随设置",
+1
View File
@@ -18,6 +18,7 @@
"exit": "退出",
"pressBackAgainToExit": "再按一次返回鍵退出應用程式",
"create": "建立任務",
"createAction": "建立",
"directDownload": "直接下載",
"advancedOptions": "進階選項",
"followSettings": "跟隨設定",
@@ -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<shad.SecondaryButtonTheme>(
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<shad.TextFieldTheme>(
data: shad.TextFieldTheme(
filled: true,
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
padding: AppDesignTokens.textFieldPadding,
),
child: shad.ComponentTheme<shad.CheckboxTheme>(
data: shad.CheckboxTheme(
size: AppDesignTokens.checkboxSize,
gap: AppDesignTokens.checkboxLabelGap,
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border,
child: shad.ComponentTheme<shad.SecondaryButtonTheme>(
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<shad.ToastTheme>(
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)),
child: child,
child: shad.ComponentTheme<shad.CheckboxTheme>(
data: shad.CheckboxTheme(
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,
),
),
),
),
@@ -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);
@@ -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(
@@ -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<AppNumberInput> {
: TextInputType.number,
inputFormatters: formatters,
enabled: widget.enabled,
padding: AppDesignTokens.numberTextFieldPadding,
features: [
shad.InputFeature.spinner(
step: widget.step,
File diff suppressed because it is too large. Load diff
@@ -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<AppLoadingButton>(create).loading, isTrue);
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
expect(tester.getRect(cancel), cancelRect);
expect(tester.getRect(create), createRect);
expect(tester.takeException(), isNull);
});
}
}
}
@@ -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<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 {
late shad.CheckboxTheme checkboxTheme;
@@ -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<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(tester.getRect(loader).center, bounds.center);
});
+63 -3
View File
@@ -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<shad.TextField>(createDirectoryInput);
final createRenameField = tester.widget<shad.TextField>(
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<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 {
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<VirtualTreeView<dynamic>>(treeFinder);
expect(tree.branchLine, same(shad.BranchLine.path));
expect(tree.rowHeight, 36);
expect(
tester.widgetList<shad.Checkbox>(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()),
);
}
}