mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-05 16:15:43 +00:00
Merge branch 'main' into codex/async-ffi-worker-callbacks
This commit is contained in:
21 files changed
+604
-193
No files matched your search
@@ -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:
|
||||
|
||||
@@ -78,6 +78,7 @@ class _GopeedAppState extends ConsumerState<GopeedApp> with WidgetsBindingObserv
|
||||
|
||||
return shad.ShadcnApp.router(
|
||||
debugShowCheckedModeBanner: false,
|
||||
disableBrowserContextMenu: false,
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
theme: AppTheme.light(themeColor),
|
||||
darkTheme: AppTheme.dark(themeColor),
|
||||
|
||||
@@ -54,6 +54,9 @@ class AppPlatformState {
|
||||
enum AppUpdateStatus { idle, checking, upToDate, available, failed }
|
||||
|
||||
class AppPlatformController extends AsyncNotifier<AppPlatformState> 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<AppPlatformState> with WindowL
|
||||
|
||||
@override
|
||||
Future<AppPlatformState> 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<void> _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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
? <TextInputFormatter>[_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)]
|
||||
: <TextInputFormatter>[
|
||||
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});
|
||||
|
||||
|
||||
+2
-1
@@ -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<DownloadCategoryDraft?> 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),
|
||||
|
||||
@@ -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<String?> 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<GithubMirrorDraft?> 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)),
|
||||
|
||||
@@ -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<CreateTaskWindowPage> {
|
||||
_initialLabels = req.labels == null ? null : Map<String, String>.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<CreateTaskWindowPage> {
|
||||
if (extra.header.isNotEmpty) {
|
||||
_replaceHttpHeaders(extra.header);
|
||||
}
|
||||
_showAdvanced = true;
|
||||
}
|
||||
break;
|
||||
case _TaskProtocol.bt:
|
||||
@@ -197,7 +195,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (req.extra is Map) {
|
||||
final extra = ReqExtraBt.fromJson(Map<String, dynamic>.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<CreateTaskWindowPage> {
|
||||
_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<CreateTaskWindowPage> {
|
||||
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<CreateTaskWindowPage> {
|
||||
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<CreateTaskWindowPage> {
|
||||
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<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
key: const ValueKey('create-task-advanced-section'),
|
||||
duration: _advancedExpandDuration,
|
||||
firstCurve: Curves.easeOutCubic,
|
||||
secondCurve: Curves.easeInCubic,
|
||||
@@ -643,7 +651,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
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<CreateTaskWindowPage> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TaskUrlUpdate?> 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,
|
||||
|
||||
@@ -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<String, String>? headers, Iterable<String> 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,
|
||||
|
||||
@@ -38,7 +38,7 @@ class AppLoadingButton extends StatelessWidget {
|
||||
children: [
|
||||
leading,
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
effectiveChild,
|
||||
Flexible(child: effectiveChild),
|
||||
],
|
||||
);
|
||||
final effectiveOnPressed = loading ? null : onPressed;
|
||||
|
||||
@@ -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<AppNumberInput> createState() => _AppNumberInputState();
|
||||
}
|
||||
|
||||
class _AppNumberInputState extends State<AppNumberInput> {
|
||||
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
|
||||
? <TextInputFormatter>[_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)]
|
||||
: <TextInputFormatter>[
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<AppPathPickerField> {
|
||||
onEnter: (_) => _syncTooltipPath(),
|
||||
child: AppTooltip(
|
||||
message: _tooltipPath,
|
||||
child: shad.TextField(
|
||||
child: AppTextField(
|
||||
key: widget.fieldKey,
|
||||
controller: widget.controller,
|
||||
hintText: widget.hintText,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void>.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<AppPlatformState> build() {
|
||||
buildCount++;
|
||||
return super.build();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VersionInfo?> checkForUpdate() async {
|
||||
updateCheckCount++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _TestRuntimeController extends AppRuntimeController {
|
||||
final _pendingRuntime = Completer<AppRuntimeState>();
|
||||
|
||||
@override
|
||||
Future<AppRuntimeState> 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Padding>(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<shad.TextField>(connectionsInput);
|
||||
expect(connectionsField.controller!.text, '12');
|
||||
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
|
||||
|
||||
final advancedSection = find.byKey(const ValueKey('create-task-advanced-section'));
|
||||
expect(tester.widget<AnimatedCrossFade>(advancedSection).crossFadeState, CrossFadeState.showSecond);
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<AnimatedCrossFade>(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');
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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<AppLoadingButton>(saveButton).onPressed, isNotNull);
|
||||
|
||||
@@ -2302,7 +2305,7 @@ void main() {
|
||||
expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height);
|
||||
final createDirectoryField = tester.widget<shad.TextField>(createDirectoryInput);
|
||||
final createRenameField = tester.widget<shad.TextField>(
|
||||
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<shad.TextField>(firstNameField).controller!.text, 'Authorization');
|
||||
expect(tester.widget<shad.TextField>(firstValueField).controller!.text, 'Bearer old');
|
||||
expect(tester.getSize(firstValue).width / tester.getSize(firstName).width, closeTo(1.618, 0.01));
|
||||
|
||||
Reference in new issue
Block a user