| 1 | import 'package:cake_wallet/typography.dart'; |
| 2 | import 'package:flutter/material.dart'; |
| 3 | import 'package:flutter/services.dart'; |
| 4 | |
| 5 | class NumberTextField extends StatefulWidget { |
| 6 | final TextEditingController? controller; |
| 7 | final FocusNode? focusNode; |
| 8 | final int min; |
| 9 | final int max; |
| 10 | final int step; |
| 11 | final double arrowsWidth; |
| 12 | final double arrowsHeight; |
| 13 | final EdgeInsets contentPadding; |
| 14 | final double borderWidth; |
| 15 | final ValueChanged<int?>? onChanged; |
| 16 | |
| 17 | const NumberTextField({ |
| 18 | Key? key, |
| 19 | this.controller, |
| 20 | this.focusNode, |
| 21 | this.min = 0, |
| 22 | this.max = 999, |
| 23 | this.step = 1, |
| 24 | this.arrowsWidth = 24, |
| 25 | this.arrowsHeight = kMinInteractiveDimension, |
| 26 | this.contentPadding = const EdgeInsets.symmetric(horizontal: 8), |
| 27 | this.borderWidth = 2, |
| 28 | this.onChanged, |
| 29 | }) : super(key: key); |
| 30 | |
| 31 | @override |
| 32 | State<StatefulWidget> createState() => _NumberTextFieldState(); |
| 33 | } |
| 34 | |
| 35 | class _NumberTextFieldState extends State<NumberTextField> { |
| 36 | late TextEditingController _controller; |
| 37 | late FocusNode _focusNode; |
| 38 | bool _canGoUp = false; |
| 39 | bool _canGoDown = false; |
| 40 | |
| 41 | @override |
| 42 | void initState() { |
| 43 | super.initState(); |
| 44 | _controller = widget.controller ?? TextEditingController(); |
| 45 | _focusNode = widget.focusNode ?? FocusNode(); |
| 46 | _updateArrows(int.tryParse(_controller.text)); |
| 47 | } |
| 48 | |
| 49 | @override |
| 50 | void didUpdateWidget(covariant NumberTextField oldWidget) { |
| 51 | super.didUpdateWidget(oldWidget); |
| 52 | _controller = widget.controller ?? _controller; |
| 53 | _focusNode = widget.focusNode ?? _focusNode; |
| 54 | _updateArrows(int.tryParse(_controller.text)); |
| 55 | } |
| 56 | |
| 57 | @override |
| 58 | Widget build(BuildContext context) => TextField( |
| 59 | style: Theme.of(context).textTheme.titleMedium!, |
| 60 | enableInteractiveSelection: false, |
| 61 | textAlign: TextAlign.center, |
| 62 | textAlignVertical: TextAlignVertical.bottom, |
| 63 | controller: _controller, |
| 64 | focusNode: _focusNode, |
| 65 | textInputAction: TextInputAction.done, |
| 66 | keyboardType: TextInputType.number, |
| 67 | maxLength: widget.max.toString().length + (widget.min.isNegative ? 1 : 0), |
| 68 | decoration: InputDecoration( |
| 69 | border: InputBorder.none, |
| 70 | contentPadding: EdgeInsets.all(0), |
| 71 | fillColor: Colors.transparent, |
| 72 | counterText: '', |
| 73 | isDense: true, |
| 74 | filled: true, |
| 75 | suffixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), |
| 76 | prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), |
| 77 | prefixIcon: Material( |
| 78 | type: MaterialType.transparency, |
| 79 | child: InkWell( |
| 80 | child: Container( |
| 81 | width: widget.arrowsWidth, |
| 82 | alignment: Alignment.bottomCenter, |
| 83 | child: Icon(Icons.keyboard_arrow_left_outlined, size: widget.arrowsWidth)), |
| 84 | onTap: _canGoDown ? () => _update(false) : null)), |
| 85 | suffixIcon: Material( |
| 86 | type: MaterialType.transparency, |
| 87 | child: InkWell( |
| 88 | child: Container( |
| 89 | width: widget.arrowsWidth, |
| 90 | alignment: Alignment.bottomCenter, |
| 91 | child: Icon(Icons.keyboard_arrow_right_outlined, size: widget.arrowsWidth)), |
| 92 | onTap: _canGoUp ? () => _update(true) : null))), |
| 93 | maxLines: 1, |
| 94 | onChanged: (value) { |
| 95 | final intValue = int.tryParse(value); |
| 96 | widget.onChanged?.call(intValue); |
| 97 | _updateArrows(intValue); |
| 98 | }, |
| 99 | inputFormatters: [_NumberTextInputFormatter(widget.min, widget.max)]); |
| 100 | |
| 101 | void _update(bool up) { |
| 102 | var intValue = int.tryParse(_controller.text); |
| 103 | intValue == null ? intValue = widget.min : intValue += up ? widget.step : -widget.step; |
| 104 | intValue = intValue.clamp(widget.min, widget.max); // Ensure intValue is within range |
| 105 | _controller.text = intValue.toString(); |
| 106 | |
| 107 | // Manually call the onChanged callback after updating the controller's text |
| 108 | widget.onChanged?.call(intValue); |
| 109 | |
| 110 | _updateArrows(intValue); |
| 111 | _focusNode.requestFocus(); |
| 112 | } |
| 113 | |
| 114 | void _updateArrows(int? value) { |
| 115 | final canGoUp = value == null || value < widget.max; |
| 116 | final canGoDown = value == null || value > widget.min; |
| 117 | if (_canGoUp != canGoUp || _canGoDown != canGoDown) |
| 118 | setState(() { |
| 119 | _canGoUp = canGoUp; |
| 120 | _canGoDown = canGoDown; |
| 121 | }); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | class _NumberTextInputFormatter extends TextInputFormatter { |
| 126 | final int min; |
| 127 | final int max; |
| 128 | |
| 129 | _NumberTextInputFormatter(this.min, this.max); |
| 130 | |
| 131 | @override |
| 132 | TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { |
| 133 | if (const ['-', ''].contains(newValue.text)) return newValue; |
| 134 | final intValue = int.tryParse(newValue.text); |
| 135 | if (intValue == null) return oldValue; |
| 136 | |
| 137 | if (intValue < min) { |
| 138 | final text = min.toString(); |
| 139 | return TextEditingValue( |
| 140 | text: text, |
| 141 | selection: TextSelection.collapsed(offset: text.length), |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | if (intValue > max) { |
| 146 | final text = max.toString(); |
| 147 | return TextEditingValue( |
| 148 | text: text, |
| 149 | selection: TextSelection.collapsed(offset: text.length), |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | return newValue.copyWith(text: intValue.toString()); |
| 154 | } |
| 155 | } |