fix(ui): use typed controls for extension settings (#1509)

This commit is contained in:
Levi authored and GitHub committed 2026-09-13 14:29:46 +08:00
1 parent 62e8ab3cef
commit a29f4bb42a
3 files changed
+168 -34

No files matched your search

@@ -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});
@@ -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<TextEditingValue>(
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<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) => LayoutBuilder(
builder: (context, constraints) => Select<String>(
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<String>(
items: SelectItemList(
children: [
for (final option in options)
SelectItemButton<String>(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,
);
}
}
@@ -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<void> 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<Switch>(find.byType(Switch)).value, isTrue);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(controller.text, 'false');
expect(tester.widget<Switch>(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<String>));
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');
});
}