mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
fix(ui): use typed controls for extension settings (#1509)
This commit is contained in:
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+79
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user