mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 16:40:32 +00:00
fix(flutter): keep create task advanced options collapsed (#1481)
This commit is contained in:
4 files changed
+170
-151
No files matched your search
@@ -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
|
||||
? <TextInputFormatter>[_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)]
|
||||
: <TextInputFormatter>[
|
||||
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});
|
||||
|
||||
|
||||
@@ -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<CreateTaskWindowPage> {
|
||||
_initialLabels = req.labels == null ? null : Map<String, String>.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<CreateTaskWindowPage> {
|
||||
if (extra.header.isNotEmpty) {
|
||||
_replaceHttpHeaders(extra.header);
|
||||
}
|
||||
_showAdvanced = true;
|
||||
}
|
||||
break;
|
||||
case _TaskProtocol.bt:
|
||||
@@ -197,7 +194,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (req.extra is Map) {
|
||||
final extra = ReqExtraBt.fromJson(Map<String, dynamic>.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<CreateTaskWindowPage> {
|
||||
_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<CreateTaskWindowPage> {
|
||||
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<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
key: const ValueKey('create-task-advanced-section'),
|
||||
duration: _advancedExpandDuration,
|
||||
firstCurve: Curves.easeOutCubic,
|
||||
secondCurve: Curves.easeInCubic,
|
||||
|
||||
@@ -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<AppNumberInput> createState() => _AppNumberInputState();
|
||||
}
|
||||
|
||||
class _AppNumberInputState extends State<AppNumberInput> {
|
||||
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
|
||||
? <TextInputFormatter>[_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)]
|
||||
: <TextInputFormatter>[
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<shad.TextField>(connectionsInput);
|
||||
expect(connectionsField.controller!.text, '12');
|
||||
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
|
||||
|
||||
final advancedSection = find.byKey(const ValueKey('create-task-advanced-section'));
|
||||
expect(tester.widget<AnimatedCrossFade>(advancedSection).crossFadeState, CrossFadeState.showSecond);
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<AnimatedCrossFade>(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');
|
||||
|
||||
Reference in new issue
Block a user