diff --git a/ui/flutter/AGENTS.md b/ui/flutter/AGENTS.md index 484e28a0..f827385c 100644 --- a/ui/flutter/AGENTS.md +++ b/ui/flutter/AGENTS.md @@ -109,6 +109,18 @@ Guidelines: - forbidden Material product-component usage - If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`. +### 3.3.1 Text Input Rules + +- All product-facing editable text controls must use the shared `AppTextField` from `lib/shared/widgets/app_text_field.dart`. +- This rule applies to every input variant, including search fields, login and password fields, URL fields, numeric fields, multiline editors, settings controls, dialog inputs, and feature-specific form fields. +- Do not instantiate Flutter Material `TextField` or `shadcn_flutter`'s `TextField` directly in feature or page code. Direct construction is allowed only while implementing or extending a shared input primitive, with the reason documented in that primitive. +- `AppTextField` owns the cross-platform text-selection menu policy. Android, iOS, desktop, and Web must all use Flutter's adaptive native context menu so long-press and right-click selection actions follow platform behavior without `shadcn_flutter`'s full-screen context-menu overlay. +- Do not use `shadcn_flutter`'s editable-text context menu on any platform. +- Feature and page code must not override `contextMenuBuilder`. Any exception must be implemented centrally in `AppTextField`, justified by a platform requirement, and covered by Android/iOS and desktop tests. +- Preserve existing field behavior when migrating to `AppTextField`, including controllers, focus nodes, input formatters, autofill hints, password visibility, read-only state, input features, keyboard actions, and multiline sizing. +- Prefer stable keys or `AppTextField` when locating app inputs in widget tests. Do not couple product tests to the exact runtime type of the underlying `shadcn_flutter` implementation. +- Changes to the shared input policy must include regression coverage for Android long-press behavior and desktop right-click behavior. Web builds must keep the same shared native-menu policy. + ## 3.4) Desktop Design Metrics Use the following desktop reference metrics unless a specific screen requires a documented exception: diff --git a/ui/flutter/lib/app/app.dart b/ui/flutter/lib/app/app.dart index d93a404e..0a0e3b33 100644 --- a/ui/flutter/lib/app/app.dart +++ b/ui/flutter/lib/app/app.dart @@ -78,6 +78,7 @@ class _GopeedAppState extends ConsumerState with WidgetsBindingObserv return shad.ShadcnApp.router( debugShowCheckedModeBanner: false, + disableBrowserContextMenu: false, onGenerateTitle: (context) => context.l10n.appTitle, theme: AppTheme.light(themeColor), darkTheme: AppTheme.dark(themeColor), diff --git a/ui/flutter/lib/app/application/app_platform_controller.dart b/ui/flutter/lib/app/application/app_platform_controller.dart index 53e542d1..1d94d134 100644 --- a/ui/flutter/lib/app/application/app_platform_controller.dart +++ b/ui/flutter/lib/app/application/app_platform_controller.dart @@ -54,6 +54,9 @@ class AppPlatformState { enum AppUpdateStatus { idle, checking, upToDate, available, failed } class AppPlatformController extends AsyncNotifier with WindowListener, TrayListener { + @protected + bool get isDesktop => Util.isDesktop(); + bool _started = false; String? _trayLocaleConfig; bool _updateInitialized = false; @@ -74,30 +77,54 @@ class AppPlatformController extends AsyncNotifier with WindowL @override Future build() async { - final runtime = ref.watch(appRuntimeControllerProvider).value; - if (runtime == null || kIsWeb) { - return _snapshot(); - } - if (!Util.isDesktop()) { - _scheduleInitialUpdateCheck(); - return _snapshot(); - } - if (_started) { - final localeConfig = runtime.downloaderConfig.extra.locale; - if (_trayLocaleConfig != localeConfig) await _initTray(); - return _snapshot(); - } - await _start(); - _scheduleInitialUpdateCheck(); + ref.listen(appRuntimeControllerProvider, (_, next) { + unawaited( + _handleRuntimeChange(next.value).catchError((Object error, StackTrace stackTrace) { + if (ref.mounted) state = AsyncValue.error(error, stackTrace); + }), + ); + }); ref.onDispose(() { + if (!_started) return; + _started = false; windowManager.removeListener(this); trayManager.removeListener(this); unawaited(HostRpcService.instance.stop()); unawaited(AppWindowCapabilityHost.instance.stop()); }); + final runtime = ref.read(appRuntimeControllerProvider).value; + if (runtime == null || kIsWeb) { + return _snapshot(); + } + if (!isDesktop) { + _scheduleInitialUpdateCheck(); + return _snapshot(); + } + if (_started) { + return _snapshot(); + } + await _start(); + _scheduleInitialUpdateCheck(); return _snapshot(); } + Future _handleRuntimeChange(AppRuntimeState? runtime) async { + if (runtime == null || kIsWeb) return; + if (!isDesktop) { + _scheduleInitialUpdateCheck(); + return; + } + if (!_started) { + await _start(); + _scheduleInitialUpdateCheck(); + _emit(); + return; + } + if (_trayLocaleConfig != runtime.downloaderConfig.extra.locale) { + await _initTray(); + } + } + void _scheduleInitialUpdateCheck() { if (_updateInitialized) return; _updateInitialized = true; diff --git a/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart b/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart index 55b40220..f090e820 100644 --- a/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart +++ b/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart @@ -6,6 +6,7 @@ import '../../../../l10n/l10n.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; class LoginForm extends StatelessWidget { const LoginForm({ @@ -159,7 +160,7 @@ class _LoginField extends StatelessWidget { style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(height: AppDesignTokens.space8), - shad.TextField( + AppTextField( key: fieldKey, controller: controller, focusNode: focusNode, 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 9c2f57ab..a9602111 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -15,6 +15,7 @@ import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.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'; import '../../../../l10n/l10n.dart'; @@ -629,7 +630,7 @@ class _Toolbar extends StatelessWidget { Widget build(BuildContext context) { final palette = AppPalette.of(context); final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey); - final search = shad.TextField( + final search = AppTextField( key: const ValueKey('extension-search-input'), controller: searchController, placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)), @@ -826,7 +827,7 @@ class _InstallPopoverState extends State<_InstallPopover> { style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), ), const SizedBox(height: 10), - shad.TextField( + AppTextField( key: const ValueKey('extension-install-url-input'), controller: widget.controller, autofocus: true, @@ -1222,9 +1223,9 @@ class _ExtensionSettingField extends StatelessWidget { ], const SizedBox(height: 8), if (setting.type == api_extension.SettingType.boolean) - shad.TextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint)) + AppTextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint)) else - shad.TextField( + AppTextField( controller: controller, keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text, ), diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart index 25063d30..595d78b4 100644 --- a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart +++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart @@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; class TasksTopBar extends StatelessWidget { @@ -110,7 +111,7 @@ class _SearchField extends StatelessWidget { ), child: SizedBox( height: 40, - child: shad.TextField( + child: AppTextField( controller: controller, style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1), placeholder: Text( 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 89d16495..22da948c 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -3,7 +3,6 @@ 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/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -23,7 +22,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_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'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/responsive_menu_layout.dart'; @@ -1849,7 +1850,7 @@ class _TextSettingControl extends StatelessWidget { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return SizedBox( width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, - child: shad.TextField( + child: AppTextField( key: fieldKey, controller: controller, hintText: hintText, @@ -1884,7 +1885,7 @@ class _NumberSettingControl extends StatelessWidget { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return SizedBox( width: desktop ? AppDesignTokens.settingsNumberControlWidth : double.infinity, - child: _NumberInput( + child: AppNumberInput( fieldKey: fieldKey, controller: controller, min: min, @@ -1919,7 +1920,7 @@ class _HostPortControl extends StatelessWidget { child: Row( children: [ Expanded( - child: shad.TextField( + child: AppTextField( key: hostKey, controller: hostController, hintText: context.l10n.server, @@ -1929,7 +1930,7 @@ class _HostPortControl extends StatelessWidget { const SizedBox(width: AppDesignTokens.space8), SizedBox( width: AppDesignTokens.settingsNumberControlWidth, - child: _NumberInput( + child: AppNumberInput( fieldKey: portKey, controller: portController, min: 0, @@ -1946,141 +1947,6 @@ class _HostPortControl extends StatelessWidget { } } -class _NumberInput extends StatefulWidget { - const _NumberInput({ - required this.controller, - required this.min, - required this.step, - required this.decimalPlaces, - this.fieldKey, - this.max, - this.hintText, - this.enabled = true, - }); - - final TextEditingController controller; - final num min; - final num? max; - final double step; - final int decimalPlaces; - final Key? fieldKey; - final String? hintText; - final bool enabled; - - @override - State<_NumberInput> createState() => _NumberInputState(); -} - -class _NumberInputState extends State<_NumberInput> { - bool _normalizing = false; - - @override - void initState() { - super.initState(); - widget.controller.addListener(_normalizeDecimalStepValue); - } - - @override - void didUpdateWidget(covariant _NumberInput oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller == widget.controller) return; - oldWidget.controller.removeListener(_normalizeDecimalStepValue); - widget.controller.addListener(_normalizeDecimalStepValue); - } - - @override - void dispose() { - widget.controller.removeListener(_normalizeDecimalStepValue); - super.dispose(); - } - - void _normalizeDecimalStepValue() { - if (_normalizing || widget.decimalPlaces <= 0) return; - final text = widget.controller.text; - final separator = text.indexOf('.'); - if (separator < 0 || text.length - separator - 1 <= widget.decimalPlaces) return; - final value = double.tryParse(text); - if (value == null) return; - - var normalized = value.toStringAsFixed(widget.decimalPlaces); - normalized = normalized.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), ''); - if (normalized == text) return; - - _normalizing = true; - widget.controller.value = TextEditingValue( - text: normalized, - selection: TextSelection.collapsed(offset: normalized.length), - ); - _normalizing = false; - } - - @override - Widget build(BuildContext context) { - final formatters = widget.decimalPlaces > 0 - ? [_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)] - : [ - FilteringTextInputFormatter.digitsOnly, - _NumericalRangeFormatter(min: widget.min.toInt(), max: widget.max?.toInt()), - ]; - return shad.TextField( - key: widget.fieldKey, - controller: widget.controller, - hintText: widget.hintText, - keyboardType: widget.decimalPlaces > 0 - ? const TextInputType.numberWithOptions(decimal: true) - : TextInputType.number, - inputFormatters: formatters, - enabled: widget.enabled, - features: [ - shad.InputFeature.spinner( - step: widget.step, - min: widget.min.toDouble(), - max: widget.max?.toDouble(), - invalidValue: widget.min.toDouble(), - enableGesture: false, - ), - ], - ); - } -} - -/// Keeps integer text within the same inclusive bounds as the legacy settings UI. -/// Empty text is allowed while editing; the previous valid value remains in config -/// until the user enters a number again. -class _NumericalRangeFormatter extends TextInputFormatter { - const _NumericalRangeFormatter({required this.min, this.max}); - - final int min; - final int? max; - - @override - TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { - if (newValue.text.isEmpty) return newValue; - final parsed = int.tryParse(newValue.text); - if (parsed == null) return oldValue; - final bounded = parsed.clamp(min, max ?? parsed).toInt(); - if (bounded == parsed) return newValue; - final text = bounded.toString(); - return TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - ); - } -} - -/// Allows a non-negative decimal number with a bounded number of fractional digits. -class _DecimalNumberFormatter extends TextInputFormatter { - _DecimalNumberFormatter({required this.decimalPlaces}) : _pattern = RegExp('^\\d*(?:\\.\\d{0,$decimalPlaces})?\$'); - - final int decimalPlaces; - final RegExp _pattern; - - @override - TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { - return _pattern.hasMatch(newValue.text) ? newValue : oldValue; - } -} - class _ExternalTextLink extends StatefulWidget { const _ExternalTextLink({super.key, required this.label, required this.onPressed}); diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart index 4f93d0ae..ae8d78ee 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -8,6 +8,7 @@ import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; class DownloadCategoryDraft { @@ -169,7 +170,7 @@ Future showDownloadCategoryDialog( children: [ Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - shad.TextField(key: const ValueKey('download-category-name'), controller: nameController), + AppTextField(key: const ValueKey('download-category-name'), controller: nameController), const SizedBox(height: 14), Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), 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 4dfc501c..56ae7c69 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 @@ -9,6 +9,7 @@ import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; class SettingsListEntry { @@ -189,7 +190,7 @@ Future showTextSettingDialog( Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), if (pickPath == null) - shad.TextField(controller: controller) + AppTextField(controller: controller) else AppPathPickerField( controller: controller, @@ -302,7 +303,7 @@ Future showGithubMirrorDialog(BuildContext context, {GithubM const SizedBox(height: 14), Text(dialogContext.l10n.githubMirrorUrl, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - shad.TextField(controller: controller), + AppTextField(controller: controller), if (validationMessage != null) ...[ const SizedBox(height: 8), Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)), 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 325f5b4a..889cd482 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,8 +29,10 @@ 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_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'; import '../../../../l10n/l10n.dart'; @@ -177,9 +179,6 @@ class _CreateTaskWindowPageState extends ConsumerState { _initialLabels = req.labels == null ? null : Map.of(req.labels!); _applyInitialProxy(req.proxy); _skipVerifyCert = req.skipVerifyCert; - if (req.proxy != null || req.skipVerifyCert) { - _showAdvanced = true; - } switch (_parseProtocol(req.url)) { case _TaskProtocol.http: if (req.extra is Map) { @@ -189,7 +188,6 @@ class _CreateTaskWindowPageState extends ConsumerState { if (extra.header.isNotEmpty) { _replaceHttpHeaders(extra.header); } - _showAdvanced = true; } break; case _TaskProtocol.bt: @@ -197,7 +195,6 @@ class _CreateTaskWindowPageState extends ConsumerState { if (req.extra is Map) { final extra = ReqExtraBt.fromJson(Map.from(req.extra! as Map)); _trackersController.text = extra.trackers.join('\n'); - _showAdvanced = true; } break; case _TaskProtocol.ed2k: @@ -222,13 +219,6 @@ class _CreateTaskWindowPageState extends ConsumerState { _autoExtract = extra.autoExtract; _archivePasswordController.text = extra.archivePassword; _deleteAfterExtract = extra.deleteAfterExtract; - if (extra.autoTorrent != null || - extra.deleteTorrentAfterDownload != null || - extra.autoExtract != null || - extra.archivePassword.isNotEmpty || - extra.deleteAfterExtract) { - _showAdvanced = true; - } } } }); @@ -268,7 +258,12 @@ class _CreateTaskWindowPageState extends ConsumerState { final page = Scaffold( backgroundColor: palette.sideBg, child: Padding( - padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0), + key: const ValueKey('create-task-safe-content'), + padding: EdgeInsets.only( + top: AppWindowChrome.reservesHeaderInset + ? AppDesignTokens.windowHeaderHeight + : MediaQuery.paddingOf(context).top, + ), child: Column( children: [ Container( @@ -311,7 +306,7 @@ class _CreateTaskWindowPageState extends ConsumerState { children: [ SizedBox( height: 120, - child: TextField( + child: AppTextField( controller: _urlController, hintText: context.l10n.pasteDownloadLinks, keyboardType: TextInputType.multiline, @@ -357,7 +352,19 @@ class _CreateTaskWindowPageState extends ConsumerState { const SizedBox(height: 16), _FormRow( label: context.l10n.connections, - child: _WindowTextField(controller: _connectionsController, hintText: context.l10n.enterCount), + 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, + ), + ), + ), ), const SizedBox(height: 16), _FormRow( @@ -434,6 +441,7 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), AnimatedCrossFade( + key: const ValueKey('create-task-advanced-section'), duration: _advancedExpandDuration, firstCurve: Curves.easeOutCubic, secondCurve: Curves.easeInCubic, @@ -643,7 +651,7 @@ class _CreateTaskWindowPageState extends ConsumerState { label: context.l10n.trackers, child: SizedBox( height: 96, - child: TextField( + child: AppTextField( controller: _trackersController, hintText: context.l10n.oneTrackerPerLine, keyboardType: TextInputType.multiline, @@ -1231,7 +1239,7 @@ class _CreateTaskWindowPageState extends ConsumerState { children: [ SizedBox( height: 38, - child: TextField( + child: AppTextField( key: const ValueKey('create-history-filter'), controller: filterController, placeholder: Text( @@ -1658,7 +1666,7 @@ class _WindowTextField extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - return TextField( + return AppTextField( controller: controller, hintText: hintText, keyboardType: keyboardType, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 0e6f6f57..0c96de5c 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -9,6 +9,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_copy_icon_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../core/utils/text_wrap.dart'; @@ -512,7 +513,7 @@ class _EditableUrlBlock extends StatelessWidget { children: [ SizedBox( height: 76, - child: shad.TextField( + child: AppTextField( controller: controller, minLines: null, maxLines: null, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart index 33a3bc82..9dd7c6ff 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart @@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_http_headers_editor.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; import '../../domain/task_record.dart'; @@ -47,7 +48,7 @@ Future showTaskUpdateUrlDialog(BuildContext context, TaskRecord children: [ Text(dialogContext.l10n.downloadLink, style: TextStyle(color: palette.textPrimary, fontSize: 12)), const SizedBox(height: 6), - TextField(controller: urlController), + AppTextField(controller: urlController), const SizedBox(height: 18), AppHttpHeadersEditor( controller: headersController, diff --git a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart index 9ccf53ca..332448d4 100644 --- a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart +++ b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart @@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../theme/app_design_tokens.dart'; import '../theme/app_palette.dart'; import '../../l10n/l10n.dart'; +import 'app_text_field.dart'; class AppHttpHeadersController extends ChangeNotifier { AppHttpHeadersController({Map? headers, Iterable defaultNames = const []}) { @@ -171,7 +172,7 @@ class _HeaderTextField extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - return shad.TextField( + return AppTextField( controller: controller, hintText: hintText, filled: true, diff --git a/ui/flutter/lib/shared/widgets/app_loading_button.dart b/ui/flutter/lib/shared/widgets/app_loading_button.dart index dd5dde4a..b203ed33 100644 --- a/ui/flutter/lib/shared/widgets/app_loading_button.dart +++ b/ui/flutter/lib/shared/widgets/app_loading_button.dart @@ -38,7 +38,7 @@ class AppLoadingButton extends StatelessWidget { children: [ leading, const SizedBox(width: AppDesignTokens.space8), - effectiveChild, + Flexible(child: effectiveChild), ], ); final effectiveOnPressed = loading ? null : onPressed; diff --git a/ui/flutter/lib/shared/widgets/app_number_input.dart b/ui/flutter/lib/shared/widgets/app_number_input.dart new file mode 100644 index 00000000..cda9545c --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_number_input.dart @@ -0,0 +1,141 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import 'app_text_field.dart'; + +/// A bounded numeric text field with the app's standard spinner controls. +class AppNumberInput extends StatefulWidget { + const AppNumberInput({ + super.key, + required this.controller, + required this.min, + this.fieldKey, + this.max, + this.step = 1, + this.decimalPlaces = 0, + this.hintText, + this.enabled = true, + }); + + final TextEditingController controller; + final num min; + final Key? fieldKey; + final num? max; + final double step; + final int decimalPlaces; + final String? hintText; + final bool enabled; + + @override + State createState() => _AppNumberInputState(); +} + +class _AppNumberInputState extends State { + bool _normalizing = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_normalizeDecimalStepValue); + } + + @override + void didUpdateWidget(covariant AppNumberInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller == widget.controller) return; + oldWidget.controller.removeListener(_normalizeDecimalStepValue); + widget.controller.addListener(_normalizeDecimalStepValue); + } + + @override + void dispose() { + widget.controller.removeListener(_normalizeDecimalStepValue); + super.dispose(); + } + + void _normalizeDecimalStepValue() { + if (_normalizing || widget.decimalPlaces <= 0) return; + final text = widget.controller.text; + final separator = text.indexOf('.'); + if (separator < 0 || text.length - separator - 1 <= widget.decimalPlaces) return; + final value = double.tryParse(text); + if (value == null) return; + + var normalized = value.toStringAsFixed(widget.decimalPlaces); + normalized = normalized.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), ''); + if (normalized == text) return; + + _normalizing = true; + widget.controller.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + _normalizing = false; + } + + @override + Widget build(BuildContext context) { + final formatters = widget.decimalPlaces > 0 + ? [_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)] + : [ + FilteringTextInputFormatter.digitsOnly, + _NumericalRangeFormatter(min: widget.min.toInt(), max: widget.max?.toInt()), + ]; + return AppTextField( + key: widget.fieldKey, + controller: widget.controller, + hintText: widget.hintText, + keyboardType: widget.decimalPlaces > 0 + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, + inputFormatters: formatters, + enabled: widget.enabled, + features: [ + shad.InputFeature.spinner( + step: widget.step, + min: widget.min.toDouble(), + max: widget.max?.toDouble(), + invalidValue: widget.min.toDouble(), + enableGesture: false, + ), + ], + ); + } +} + +/// Keeps integer text within the same inclusive bounds as the settings UI. +/// Empty text is allowed while editing. +class _NumericalRangeFormatter extends TextInputFormatter { + const _NumericalRangeFormatter({required this.min, this.max}); + + final int min; + final int? max; + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + if (newValue.text.isEmpty) return newValue; + final parsed = int.tryParse(newValue.text); + if (parsed == null) return oldValue; + final bounded = parsed.clamp(min, max ?? parsed).toInt(); + if (bounded == parsed) return newValue; + final text = bounded.toString(); + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } +} + +/// Allows a non-negative decimal number with a bounded number of fractional digits. +class _DecimalNumberFormatter extends TextInputFormatter { + _DecimalNumberFormatter({required this.decimalPlaces}) : _pattern = RegExp('^\\d*(?:\\.\\d{0,$decimalPlaces})?\$'); + + final int decimalPlaces; + final RegExp _pattern; + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + return _pattern.hasMatch(newValue.text) ? newValue : oldValue; + } +} 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 1cd710a9..f7094532 100644 --- a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart +++ b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart @@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../core/utils/breakpoints.dart'; import '../services/download_directory_picker.dart'; +import 'app_text_field.dart'; import 'app_tooltip.dart'; enum AppPathPickerButtonStyle { ghost, outline } @@ -119,7 +120,7 @@ class _AppPathPickerFieldState extends State { onEnter: (_) => _syncTooltipPath(), child: AppTooltip( message: _tooltipPath, - child: shad.TextField( + child: AppTextField( key: widget.fieldKey, controller: widget.controller, hintText: widget.hintText, diff --git a/ui/flutter/lib/shared/widgets/app_text_field.dart b/ui/flutter/lib/shared/widgets/app_text_field.dart new file mode 100644 index 00000000..52d8ba79 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_text_field.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +/// The app-wide text field. +/// +/// shadcn_flutter's custom context menu is rendered in a full-screen overlay +/// that can cover the app with an opaque surface. Use Flutter's adaptive +/// platform toolbar everywhere so long-press and right-click text actions have +/// consistent native behavior on mobile, desktop, and web. +class AppTextField extends shad.TextField { + const AppTextField({ + super.key, + super.groupId, + super.controller, + super.initialValue, + super.focusNode, + super.undoController, + super.decoration, + super.padding, + super.placeholder, + super.crossAxisAlignment, + super.clearButtonSemanticLabel, + super.keyboardType, + super.textInputAction, + super.textCapitalization, + super.style, + super.strutStyle, + super.textAlign, + super.textAlignVertical, + super.textDirection, + super.readOnly, + super.showCursor, + super.autofocus, + super.obscuringCharacter, + super.obscureText, + super.autocorrect, + super.smartDashesType, + super.smartQuotesType, + super.enableSuggestions, + super.maxLines, + super.minLines, + super.expands, + super.maxLength, + super.maxLengthEnforcement, + super.onChanged, + super.onEditingComplete, + super.onSubmitted, + super.onTapOutside, + super.onTapUpOutside, + super.inputFormatters, + super.enabled, + super.cursorWidth, + super.cursorHeight, + super.cursorRadius, + super.cursorOpacityAnimates, + super.cursorColor, + super.selectionHeightStyle, + super.selectionWidthStyle, + super.keyboardAppearance, + super.scrollPadding, + super.enableInteractiveSelection, + super.selectionControls, + super.dragStartBehavior, + super.scrollController, + super.scrollPhysics, + super.onTap, + super.autofillHints, + super.clipBehavior, + super.restorationId, + super.stylusHandwritingEnabled, + super.enableIMEPersonalizedLearning, + super.contentInsertionConfiguration, + super.contextMenuBuilder = appTextFieldContextMenuBuilder, + super.hintText, + super.border, + super.borderRadius, + super.filled, + super.statesController, + super.magnifierConfiguration, + super.spellCheckConfiguration, + super.features, + super.submitFormatters, + super.skipInputFeatureFocusTraversal, + super.onDragSelectionStart, + super.onDragSelectionUpdate, + super.onDragSelectionEnd, + }); +} + +Widget appTextFieldContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + // Match Flutter's platform text-menu policy without falling back to + // shadcn_flutter's full-screen overlay. EditableText bypasses this builder + // on Web while the browser context menu is enabled. + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return material.AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); +} diff --git a/ui/flutter/test/app/application/app_platform_controller_test.dart b/ui/flutter/test/app/application/app_platform_controller_test.dart new file mode 100644 index 00000000..a5795ebb --- /dev/null +++ b/ui/flutter/test/app/application/app_platform_controller_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +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_platform_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/util/updater.dart'; + +void main() { + test('runtime updates do not rebuild the platform controller', () async { + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new), + appPlatformControllerProvider.overrideWith(_TestPlatformController.new), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(appPlatformControllerProvider, (_, _) {}, fireImmediately: true); + addTearDown(subscription.close); + + await container.read(appPlatformControllerProvider.future); + final platformController = container.read(appPlatformControllerProvider.notifier) as _TestPlatformController; + final runtimeController = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + + expect(platformController.buildCount, 1); + + runtimeController.completeRuntime(); + await container.read(appRuntimeControllerProvider.future); + await Future.delayed(Duration.zero); + expect(platformController.updateCheckCount, 1); + + final config = DownloaderConfig.fromJson(runtimeController.state.requireValue.downloaderConfig.toJson()); + config.extra.locale = 'zh'; + runtimeController.replaceDownloaderConfig(config); + await container.pump(); + + expect(platformController.buildCount, 1); + }); +} + +class _TestPlatformController extends AppPlatformController { + int buildCount = 0; + int updateCheckCount = 0; + + @override + bool get isDesktop => false; + + @override + Future build() { + buildCount++; + return super.build(); + } + + @override + Future checkForUpdate() async { + updateCheckCount++; + return null; + } +} + +class _TestRuntimeController extends AppRuntimeController { + final _pendingRuntime = Completer(); + + @override + Future build() => _pendingRuntime.future; + + void completeRuntime() { + _pendingRuntime.complete( + AppRuntimeState( + startConfig: StartConfig(), + apiServerState: const ApiServerState( + enabled: false, + mcpEnabled: false, + running: false, + network: '', + address: '', + runningPort: 0, + pendingApply: false, + lastError: '', + ), + downloaderConfig: DownloaderConfig(), + ), + ); + } +} 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 0de3f9e1..c0eeae56 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 @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart' as flutter show Row; +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'; @@ -21,6 +22,39 @@ import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { + 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; + tester.view.padding = const FakeViewPadding(top: 32, bottom: 24); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPadding); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + try { + 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(); + + 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 { @@ -119,6 +153,19 @@ void main() { ); await tester.pumpAndSettle(); + 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 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'); diff --git a/ui/flutter/test/shared/widgets/app_text_field_test.dart b/ui/flutter/test/shared/widgets/app_text_field_test.dart new file mode 100644 index 00000000..cd7c35a9 --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_text_field_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar; +import 'package:flutter/services.dart' show BrowserContextMenu; +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_text_field.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('Android long press uses Flutter adaptive text selection toolbar', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final controller = TextEditingController(text: 'https://example.com/download'); + addTearDown(controller.dispose); + + try { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: SizedBox(width: 320, child: AppTextField(controller: controller)), + ), + ), + ), + ); + + await tester.longPress(find.byType(AppTextField)); + await tester.pumpAndSettle(); + + expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: kIsWeb); + + testWidgets('desktop right click uses Flutter adaptive text selection toolbar', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final controller = TextEditingController(text: 'https://example.com/download'); + addTearDown(controller.dispose); + + try { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: SizedBox(width: 320, child: AppTextField(controller: controller)), + ), + ), + ), + ); + + final field = find.byType(AppTextField); + final gesture = await tester.startGesture( + tester.getCenter(field), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: kIsWeb); + + testWidgets('Web right click keeps the browser menu and creates no Flutter overlay', (tester) async { + final controller = TextEditingController(text: 'https://example.com/download'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + disableBrowserContextMenu: false, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: SizedBox(width: 320, child: AppTextField(controller: controller)), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(BrowserContextMenu.enabled, isTrue); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(AppTextField)), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(material.AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing); + expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing); + }, skip: !kIsWeb); + + test('AppTextField defaults to the shared context menu policy', () { + expect(const AppTextField().contextMenuBuilder, same(appTextFieldContextMenuBuilder)); + }); +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 860f9138..d7fddcbe 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -81,6 +81,7 @@ import 'package:gopeed/shared/widgets/app_copy_icon_button.dart'; import 'package:gopeed/shared/widgets/app_http_headers_editor.dart'; import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; import 'package:gopeed/shared/widgets/app_primary_button.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:gopeed/shared/widgets/app_toast.dart'; import 'package:gopeed/shared/widgets/gopeed_app_mark.dart'; @@ -371,7 +372,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - await tester.enterText(find.byType(shad.TextField), 'not-a-task'); + await tester.enterText(find.byType(AppTextField), 'not-a-task'); await tester.pump(); expect(find.text('No matching tasks found'), findsOneWidget); @@ -1451,8 +1452,10 @@ void main() { await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup'))); await tester.pumpAndSettle(); - await tester.ensureVisible(find.byKey(const ValueKey('mcp-endpoint-switch'))); - await tester.tap(find.byKey(const ValueKey('mcp-endpoint-switch'))); + final mcpSwitch = find.byKey(const ValueKey('mcp-endpoint-switch')); + await Scrollable.ensureVisible(tester.element(mcpSwitch), alignment: 0.5); + await tester.pumpAndSettle(); + await tester.tap(mcpSwitch); await tester.pump(); expect(tester.widget(saveButton).onPressed, isNotNull); @@ -2302,7 +2305,7 @@ void main() { expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height); final createDirectoryField = tester.widget(createDirectoryInput); final createRenameField = tester.widget( - find.descendant(of: createRenameInput, matching: find.byType(shad.TextField)), + find.descendant(of: createRenameInput, matching: find.byType(AppTextField)), ); expect(createDirectoryField.filled, createRenameField.filled); expect(createDirectoryField.border, createRenameField.border); @@ -4339,8 +4342,8 @@ void main() { expect(find.byType(AppHttpHeadersEditor), findsOneWidget); final firstName = find.byKey(const ValueKey('update-task-http-header-name-0')); final firstValue = find.byKey(const ValueKey('update-task-http-header-value-0')); - final firstNameField = find.descendant(of: firstName, matching: find.byType(shad.TextField)); - final firstValueField = find.descendant(of: firstValue, matching: find.byType(shad.TextField)); + final firstNameField = find.descendant(of: firstName, matching: find.byType(AppTextField)); + final firstValueField = find.descendant(of: firstValue, matching: find.byType(AppTextField)); expect(tester.widget(firstNameField).controller!.text, 'Authorization'); expect(tester.widget(firstValueField).controller!.text, 'Bearer old'); expect(tester.getSize(firstValue).width / tester.getSize(firstName).width, closeTo(1.618, 0.01));