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 89d16495..8ee76f8f 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -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,6 +22,7 @@ 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_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -1884,7 +1884,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, @@ -1929,7 +1929,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 +1946,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 - ? [_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)] - : [ - 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}); diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 325f5b4a..6cfe9cc6 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -29,6 +29,7 @@ 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_tooltip.dart'; @@ -177,9 +178,6 @@ class _CreateTaskWindowPageState extends ConsumerState { _initialLabels = req.labels == null ? null : Map.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 +187,6 @@ class _CreateTaskWindowPageState extends ConsumerState { if (extra.header.isNotEmpty) { _replaceHttpHeaders(extra.header); } - _showAdvanced = true; } break; case _TaskProtocol.bt: @@ -197,7 +194,6 @@ class _CreateTaskWindowPageState extends ConsumerState { if (req.extra is Map) { final extra = ReqExtraBt.fromJson(Map.from(req.extra! as Map)); _trackersController.text = extra.trackers.join('\n'); - _showAdvanced = true; } break; case _TaskProtocol.ed2k: @@ -222,13 +218,6 @@ class _CreateTaskWindowPageState extends ConsumerState { _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; - } } } }); @@ -357,7 +346,19 @@ class _CreateTaskWindowPageState extends ConsumerState { 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 +435,7 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), AnimatedCrossFade( + key: const ValueKey('create-task-advanced-section'), duration: _advancedExpandDuration, firstCurve: Curves.easeOutCubic, secondCurve: Curves.easeInCubic, diff --git a/ui/flutter/lib/shared/widgets/app_number_input.dart b/ui/flutter/lib/shared/widgets/app_number_input.dart new file mode 100644 index 00000000..50161585 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_number_input.dart @@ -0,0 +1,139 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +/// 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 createState() => _AppNumberInputState(); +} + +class _AppNumberInputState extends State { + 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 + ? [_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)] + : [ + 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 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; + } +} diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index 0de3f9e1..dc45d9b9 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -119,6 +119,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(connectionsInput); + expect(connectionsField.controller!.text, '12'); + expect(connectionsField.features.single, isA()); + + final advancedSection = find.byKey(const ValueKey('create-task-advanced-section')); + expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showSecond); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(tester.widget(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');