From 99ed770017e53c70c49dd3c1bcb09c1c4e099b49 Mon Sep 17 00:00:00 2001 From: Levi Date: Wed, 9 Sep 2026 23:39:44 +0800 Subject: [PATCH] fix(ui): reuse shared file path picker for script settings --- .../presentation/pages/settings_page.dart | 8 +--- .../widgets/settings_list_editor.dart | 10 ++--- .../shared/widgets/app_path_picker_field.dart | 39 ++++++++++++++++++- .../widgets/app_path_picker_field_test.dart | 39 +++++++++++++++++++ 4 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 ui/flutter/test/shared/widgets/app_path_picker_field_test.dart diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index 62c2c7e3..67d41e74 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation; import 'package:flutter/widgets.dart'; @@ -1376,12 +1375,7 @@ class _SettingsPageState extends ConsumerState { title: index == null ? context.l10n.addScript : context.l10n.editScript, fieldLabel: context.l10n.scriptPath, initialValue: index == null ? '' : paths[index], - pickPath: Util.isDesktop() - ? () async { - final file = await FilePicker.pickFile(); - return file?.path; - } - : null, + pickFile: true, ); if (value == null || !mounted) return; _mutateConfig((config) { diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart index 56ae7c69..58b325ae 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -122,7 +122,7 @@ Future showTextSettingDialog( required String fieldLabel, String initialValue = '', bool requireHttpUrl = false, - Future Function()? pickPath, + bool pickFile = false, Future Function(String value)? onTest, }) async { final controller = TextEditingController(text: initialValue); @@ -189,16 +189,12 @@ Future showTextSettingDialog( children: [ Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - if (pickPath == null) + if (!pickFile) AppTextField(controller: controller) else - AppPathPickerField( + AppPathPickerField.file( controller: controller, desktopWidth: AppDesignTokens.settingsFormControlWidth, - onPick: () async { - final path = await pickPath(); - if (path != null && path.isNotEmpty) controller.text = path; - }, ), if (validationMessage != null || testMessage != null) ...[ const SizedBox(height: 8), diff --git a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart index f7094532..e14ba37c 100644 --- a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart +++ b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart @@ -1,8 +1,10 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../core/utils/breakpoints.dart'; +import '../../util/util.dart'; import '../services/download_directory_picker.dart'; import 'app_text_field.dart'; import 'app_tooltip.dart'; @@ -24,7 +26,28 @@ class AppPathPickerField extends StatefulWidget { this.padding, this.onChanged, this.pickerButtonStyle = AppPathPickerButtonStyle.ghost, - }) : platformDownloadDirectory = false, + }) : platformFile = false, + platformDownloadDirectory = false, + onDirectoryPicked = null, + allowAndroidEditing = false; + + /// Selects a native desktop file path; Web and mobile accept manual paths only. + const AppPathPickerField.file({ + super.key, + required this.controller, + this.fieldKey, + this.pickerKey, + this.hintText, + this.desktopWidth, + this.filled, + this.border, + this.borderRadius, + this.padding, + this.onChanged, + this.pickerButtonStyle = AppPathPickerButtonStyle.outline, + }) : onPick = null, + platformFile = true, + platformDownloadDirectory = false, onDirectoryPicked = null, allowAndroidEditing = false; @@ -44,6 +67,7 @@ class AppPathPickerField extends StatefulWidget { this.onChanged, this.pickerButtonStyle = AppPathPickerButtonStyle.outline, }) : onPick = null, + platformFile = false, platformDownloadDirectory = true; final TextEditingController controller; @@ -58,6 +82,7 @@ class AppPathPickerField extends StatefulWidget { final EdgeInsetsGeometry? padding; final ValueChanged? onChanged; final AppPathPickerButtonStyle pickerButtonStyle; + final bool platformFile; final bool platformDownloadDirectory; final ValueChanged? onDirectoryPicked; final bool allowAndroidEditing; @@ -97,6 +122,16 @@ class _AppPathPickerFieldState extends State { setState(() => _tooltipPath = path); } + Future _pickFile() async { + if (!Util.isDesktop()) return; + final file = await FilePicker.pickFile(); + if (!mounted) return; + final path = file?.path; + if (path == null || path.isEmpty || path == widget.controller.text) return; + widget.controller.text = path; + widget.onChanged?.call(path); + } + Future _pickPlatformDirectory() async { final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim()); if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return; @@ -110,6 +145,8 @@ class _AppPathPickerFieldState extends State { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final onPick = widget.platformDownloadDirectory ? (DownloadDirectoryPicker.canPick ? _pickPlatformDirectory : null) + : widget.platformFile + ? (Util.isDesktop() ? _pickFile : null) : widget.onPick; return SizedBox( width: desktop ? widget.desktopWidth : double.infinity, diff --git a/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart b/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart new file mode 100644 index 00000000..78321a1e --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('file paths remain editable and Web hides native file selection', (tester) async { + final controller = TextEditingController(text: '/scripts/old.sh'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: AppPathPickerField.file( + controller: controller, + desktopWidth: 320, + pickerKey: const ValueKey('file-picker'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('file-picker')), kIsWeb ? findsNothing : findsOneWidget); + expect(tester.widget(find.byType(AppTextField)).readOnly, isFalse); + await tester.enterText(find.byType(EditableText), '/scripts/custom.sh'); + await tester.pump(); + expect(controller.text, '/scripts/custom.sh'); + expect(tester.takeException(), isNull); + }); +}