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 5aa57e6a..b77cbe21 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -24,6 +24,7 @@ import '../../application/extensions_controller.dart'; import '../../application/pending_extension_install.dart'; import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; +import '../widgets/extension_setting_field.dart'; const _extensionCardMinWidth = 280.0; const _extensionGridSpacing = 10.0; @@ -1174,7 +1175,7 @@ class _ExtensionSettingsPanel extends StatelessWidget { separatorBuilder: (_, _) => const SizedBox(height: 14), itemBuilder: (context, index) { final setting = settings[index]; - return _ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); + return ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); }, ), ), @@ -1202,39 +1203,6 @@ class _ExtensionSettingsPanel extends StatelessWidget { } } -class _ExtensionSettingField extends StatelessWidget { - const _ExtensionSettingField({required this.setting, required this.controller}); - - final api_extension.Setting setting; - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - final palette = AppPalette.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - setting.title, - style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), - ), - if (setting.description.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), - ], - const SizedBox(height: 8), - if (setting.type == api_extension.SettingType.boolean) - AppTextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint)) - else - AppTextField( - controller: controller, - keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text, - ), - ], - ); - } -} - class _ErrorState extends StatelessWidget { const _ErrorState({required this.error, required this.onRetry}); diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart new file mode 100644 index 00000000..eb0d8846 --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart @@ -0,0 +1,87 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/services.dart'; + +import '../../../../api/model/extension.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_text_field.dart'; + +class ExtensionSettingField extends StatelessWidget { + const ExtensionSettingField({super.key, required this.setting, required this.controller}); + + final Setting setting; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + setting.title, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + if (setting.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), + ], + const SizedBox(height: 8), + _buildInput(), + ], + ); + } + + Widget _buildInput() { + if (setting.type == SettingType.boolean) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => + Switch(value: value.text == 'true', onChanged: (enabled) => controller.text = enabled.toString()), + ); + } + + final options = setting.options; + if (options != null && options.isNotEmpty) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => LayoutBuilder( + builder: (context, constraints) => Select( + value: options.any((option) => option.value.toString() == value.text) ? value.text : null, + placeholder: Text(setting.title), + constraints: BoxConstraints.tightFor(width: constraints.maxWidth), + popupConstraints: const BoxConstraints(maxHeight: 240), + itemBuilder: (context, selectedValue) => Text( + options.firstWhere((option) => option.value.toString() == selectedValue).label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + popup: (context) => SelectPopup( + items: SelectItemList( + children: [ + for (final option in options) + SelectItemButton(value: option.value.toString(), child: Text(option.label)), + ], + ), + ), + onChanged: (selectedValue) { + if (selectedValue != null) controller.text = selectedValue; + }, + ), + ), + ); + } + + final isNumber = setting.type == SettingType.number; + return AppTextField( + controller: controller, + keyboardType: isNumber ? const TextInputType.numberWithOptions(decimal: true, signed: true) : TextInputType.text, + inputFormatters: isNumber + ? [ + TextInputFormatter.withFunction( + (oldValue, newValue) => RegExp(r'^-?\d*\.?\d*$').hasMatch(newValue.text) ? newValue : oldValue, + ), + ] + : null, + ); + } +} diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart new file mode 100644 index 00000000..8db50b74 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_setting_field.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; + +void main() { + Future pumpField(WidgetTester tester, Setting setting, TextEditingController controller) async { + await tester.pumpWidget( + ShadcnApp( + home: Scaffold( + child: Center( + child: SizedBox( + width: 280, + child: ExtensionSettingField(setting: setting, controller: controller), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Setting setting(SettingType type) => + Setting(name: 'test', title: 'Test setting', description: 'Description', required: false, type: type); + + testWidgets('boolean switch loads and updates the saved value', (tester) async { + final controller = TextEditingController(text: 'true'); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.boolean), controller); + expect(find.byType(AppTextField), findsNothing); + expect(tester.widget(find.byType(Switch)).value, isTrue); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(controller.text, 'false'); + expect(tester.widget(find.byType(Switch)).value, isFalse); + }); + + for (final type in [SettingType.string, SettingType.number]) { + testWidgets( + '$type options show labels and save selected values', + (tester) async { + final field = setting(type)..options = [Option(label: 'First', value: 1), Option(label: 'Second', value: 2)]; + final controller = TextEditingController(text: '1'); + addTearDown(controller.dispose); + await pumpField(tester, field, controller); + expect(find.byType(AppTextField), findsNothing); + expect(find.text('First'), findsOneWidget); + await tester.tap(find.byType(Select)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Second')); + await tester.pumpAndSettle(); + expect(controller.text, '2'); + expect(find.text('Second'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}), + ); + } + + testWidgets('number field accepts decimals and rejects nonnumeric input', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.number), controller); + await tester.enterText(find.byType(AppTextField), '-12.5'); + expect(controller.text, '-12.5'); + await tester.enterText(find.byType(AppTextField), 'invalid'); + expect(controller.text, '-12.5'); + }); + + testWidgets('string with empty options remains a text field', (tester) async { + final controller = TextEditingController(text: 'original'); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.string)..options = [], controller); + await tester.enterText(find.byType(AppTextField), 'updated'); + expect(controller.text, 'updated'); + }); +}