mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 07:45:30 +00:00
144 lines
4.4 KiB
Dart
144 lines
4.4 KiB
Dart
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
|
|
|
import '../theme/app_design_tokens.dart';
|
|
import 'app_text_field.dart';
|
|
|
|
/// 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 AppTextField(
|
|
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,
|
|
padding: AppDesignTokens.numberTextFieldPadding,
|
|
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;
|
|
}
|
|
}
|