Created
March 24, 2023 13:05
-
-
Save CoderNamedHendrick/772667595664ab57e4e03a3e320e51de to your computer and use it in GitHub Desktop.
Amount input formatter with decimal entry
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
final RegExp _mantissaSeparatorRegexp = RegExp(r'[,.]'); | |
final RegExp _illegalCharsRegexp = RegExp(r'[^\d-,.]+'); | |
class AmountInputFormatter implements TextInputFormatter { | |
final int mantissaLength; | |
final int? maxTextLength; | |
final ValueChanged<num>? onValueChange; | |
const AmountInputFormatter({ | |
this.mantissaLength = 2, | |
this.onValueChange, | |
this.maxTextLength, | |
}); | |
void _updateValue(String value) { | |
if (onValueChange == null) { | |
return; | |
} | |
_widgetsBinding?.addPostFrameCallback((_) { | |
try { | |
if (mantissaLength < 1) { | |
onValueChange!(int.tryParse(value) ?? double.nan); | |
} else { | |
onValueChange!(double.tryParse(value) ?? double.nan); | |
} | |
} catch (e) { | |
onValueChange!(double.nan); | |
} | |
}); | |
} | |
dynamic get _widgetsBinding { | |
return WidgetsBinding.instance; | |
} | |
String get _mantissaSeparator { | |
return '.'; | |
} | |
@override | |
TextEditingValue formatEditUpdate( | |
TextEditingValue oldValue, | |
TextEditingValue newValue, | |
) { | |
final trailingLength = _getTrailingLength(); | |
final leadingLength = _getLeadingLength(); | |
final oldCaretIndex = max(oldValue.selection.start, oldValue.selection.end); | |
final newCaretIndex = max(newValue.selection.start, newValue.selection.end); | |
var newText = newValue.text; | |
final newAsNumeric = toNumericString( | |
newText, | |
allowPeriod: true, | |
mantissaSeparator: _mantissaSeparator, | |
); | |
_updateValue(newAsNumeric); | |
var oldText = oldValue.text; | |
if (oldValue == newValue) { | |
return newValue; | |
} | |
bool isErasing = newText.length < oldText.length; | |
if (isErasing) { | |
if (mantissaLength == 0 && oldCaretIndex == oldValue.text.length) { | |
if (trailingLength > 0) { | |
return oldValue.copyWith( | |
selection: TextSelection.collapsed( | |
offset: min( | |
oldValue.text.length, | |
oldCaretIndex - trailingLength, | |
), | |
), | |
); | |
} | |
} | |
if (_hasErasedMantissaSeparator( | |
shorterString: newText, | |
longerString: oldText, | |
)) { | |
return oldValue.copyWith( | |
selection: TextSelection.collapsed( | |
offset: min( | |
oldValue.text.length, | |
oldCaretIndex - 1, | |
), | |
), | |
); | |
} | |
} else { | |
if (_containsIllegalChars(newText)) { | |
return oldValue; | |
} | |
} | |
int afterMantissaPosition = _countAfterMantissaPosition( | |
oldText: oldText, | |
oldCaretOffset: oldCaretIndex, | |
); | |
final newAsCurrency = toCurrencyString( | |
newText, | |
mantissaLength: mantissaLength, | |
); | |
if (_switchToRightInWholePart( | |
newText: newText, | |
oldText: oldText, | |
)) { | |
return oldValue.copyWith( | |
selection: TextSelection.collapsed( | |
offset: min( | |
oldValue.text.length, | |
oldCaretIndex + 1, | |
), | |
), | |
); | |
} | |
if (afterMantissaPosition > 0) { | |
if (_switchToLeftInMantissa( | |
newText: newText, | |
oldText: oldText, | |
caretPosition: newCaretIndex, | |
)) { | |
return TextEditingValue( | |
selection: TextSelection.collapsed( | |
offset: newCaretIndex, | |
), | |
text: newAsCurrency, | |
); | |
} else { | |
int offset = min( | |
newCaretIndex, | |
newAsCurrency.length - trailingLength, | |
); | |
return TextEditingValue( | |
selection: TextSelection.collapsed( | |
offset: offset, | |
), | |
text: newAsCurrency, | |
); | |
} | |
} | |
var initialCaretOffset = leadingLength; | |
if (_isZeroOrEmpty(newAsNumeric)) { | |
int offset = min( | |
newValue.text.length, | |
initialCaretOffset + 1, | |
); | |
if (newValue.text == '') { | |
offset = 1; | |
} | |
return newValue.copyWith( | |
text: newAsCurrency, | |
selection: TextSelection.collapsed( | |
offset: offset, | |
), | |
); | |
} | |
final oldAsCurrency = toCurrencyString( | |
oldText, | |
mantissaLength: mantissaLength, | |
); | |
var lengthDiff = newAsCurrency.length - oldAsCurrency.length; | |
initialCaretOffset = max( | |
(oldCaretIndex + lengthDiff), | |
leadingLength + 1, | |
); | |
if (initialCaretOffset < 1) { | |
if (newAsCurrency.isNotEmpty) { | |
initialCaretOffset += 1; | |
} | |
} | |
return TextEditingValue( | |
selection: TextSelection.collapsed( | |
offset: initialCaretOffset, | |
), | |
text: newAsCurrency, | |
); | |
} | |
bool _isZeroOrEmpty(String? value) { | |
if (value == null || value.isEmpty) { | |
return true; | |
} | |
value = toNumericString( | |
value, | |
allowPeriod: true, | |
mantissaSeparator: _mantissaSeparator, | |
); | |
try { | |
return double.parse(value) == 0.0; | |
} catch (e) { | |
if (kDebugMode) { | |
print(e); | |
} | |
} | |
return false; | |
} | |
int _getLeadingLength() { | |
return 0; | |
} | |
int _getTrailingLength() { | |
return 0; | |
} | |
List<String> _findDifferentChars({ | |
required String longerString, | |
required String shorterString, | |
}) { | |
final newChars = longerString.split(''); | |
final oldChars = shorterString.split(''); | |
for (var i = 0; i < oldChars.length; i++) { | |
final oldChar = oldChars[i]; | |
newChars.remove(oldChar); | |
} | |
return newChars; | |
} | |
bool _containsMantissaSeparator(List<String> chars) { | |
for (var char in chars) { | |
if (char == _mantissaSeparator) { | |
return true; | |
} | |
} | |
return false; | |
} | |
bool _switchToRightInWholePart({ | |
required String newText, | |
required String oldText, | |
}) { | |
if (newText.length > oldText.length) { | |
final newChars = _findDifferentChars( | |
longerString: newText, | |
shorterString: oldText, | |
); | |
if (_containsMantissaSeparator(newChars)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
bool _switchToLeftInMantissa({ | |
required String newText, | |
required String oldText, | |
required int caretPosition, | |
}) { | |
if (newText.length < oldText.length) { | |
if (caretPosition < newText.length) { | |
var nextChar = ''; | |
if (caretPosition < newText.length - 1) { | |
nextChar = newText[caretPosition]; | |
if (!isDigit(nextChar, positiveOnly: true) || | |
int.tryParse(nextChar) == 0) { | |
return true; | |
} | |
} | |
} | |
} | |
return false; | |
} | |
int _countAfterMantissaPosition({ | |
required String oldText, | |
required int oldCaretOffset, | |
}) { | |
if (mantissaLength < 1) { | |
return 0; | |
} | |
final mantissaIndex = oldText.lastIndexOf( | |
_mantissaSeparatorRegexp, | |
); | |
if (mantissaIndex < 0) { | |
return 0; | |
} | |
if (oldCaretOffset > mantissaIndex) { | |
return oldCaretOffset - mantissaIndex; | |
} | |
return 0; | |
} | |
bool _hasErasedMantissaSeparator({ | |
required String shorterString, | |
required String longerString, | |
}) { | |
final differentChars = _findDifferentChars( | |
shorterString: shorterString, | |
longerString: longerString, | |
); | |
if (_containsMantissaSeparator(differentChars)) { | |
return true; | |
} | |
return false; | |
} | |
bool _containsIllegalChars(String input) { | |
if (input.isEmpty) return false; | |
var clearedInput = input; | |
clearedInput = clearedInput.replaceAll(' ', ''); | |
return _illegalCharsRegexp.hasMatch(clearedInput); | |
} | |
} | |
final RegExp _digitRegExp = RegExp(r'[-\d]+'); | |
final RegExp _positiveDigitRegExp = RegExp(r'\d+'); | |
final RegExp _digitWithPeriodRegExp = RegExp(r'[-\d]+(\.\d+)?'); | |
final _spaceRegex = RegExp(r'\s+'); | |
/// [errorText] if you don't want this method to throw any | |
/// errors, pass null here | |
/// [allowAllZeroes] might be useful e.g. for phone masks | |
String toNumericString( | |
String? inputString, { | |
bool allowPeriod = false, | |
bool allowHyphen = true, | |
String mantissaSeparator = '.', | |
String? errorText, | |
bool allowAllZeroes = false, | |
}) { | |
if (inputString == null) { | |
return ''; | |
} else if (inputString == '+') { | |
return inputString; | |
} | |
if (mantissaSeparator == '.') { | |
inputString = inputString.replaceAll(',', ''); | |
} else if (mantissaSeparator == ',') { | |
inputString = inputString.replaceAll('.', '').replaceAll(',', '.'); | |
} | |
var startsWithPeriod = numericStringStartsWithOrphanPeriod( | |
inputString, | |
); | |
var regexWithoutPeriod = allowHyphen ? _digitRegExp : _positiveDigitRegExp; | |
var regExp = allowPeriod ? _digitWithPeriodRegExp : regexWithoutPeriod; | |
var result = inputString.splitMapJoin( | |
regExp, | |
onMatch: (m) => m.group(0)!, | |
onNonMatch: (nm) => '', | |
); | |
if (startsWithPeriod && allowPeriod) { | |
result = '0.$result'; | |
} | |
if (result.isEmpty) { | |
return result; | |
} | |
try { | |
result = _toDoubleString( | |
result, | |
allowPeriod: allowPeriod, | |
errorText: errorText, | |
allowAllZeroes: allowAllZeroes, | |
); | |
} catch (e) { | |
if (kDebugMode) { | |
print(e); | |
} | |
} | |
return result; | |
} | |
bool numericStringStartsWithOrphanPeriod(String string) { | |
var result = false; | |
for (var i = 0; i < string.length; i++) { | |
var char = string[i]; | |
if (isDigit(char)) { | |
break; | |
} | |
if (char == '.' || char == ',') { | |
result = true; | |
break; | |
} | |
} | |
return result; | |
} | |
String _toDoubleString( | |
String input, { | |
bool allowPeriod = true, | |
String? errorText = 'Invalid number', | |
bool allowAllZeroes = false, | |
}) { | |
const period = '.'; | |
const zero = '0'; | |
const dash = '-'; | |
final temp = <String>[]; | |
if (input.startsWith(period)) { | |
if (allowPeriod) { | |
temp.add(zero); | |
} else { | |
return zero; | |
} | |
} | |
bool periodUsed = false; | |
for (var i = 0; i < input.length; i++) { | |
final char = input[i]; | |
if (!isDigit(char, positiveOnly: true)) { | |
if (char == dash) { | |
if (i > 0) { | |
if (errorText != null) { | |
throw errorText; | |
} else { | |
continue; | |
} | |
} | |
} else if (char == period) { | |
if (!allowPeriod) { | |
break; | |
} else if (periodUsed) { | |
continue; | |
} | |
periodUsed = true; | |
} | |
} | |
temp.add(char); | |
} | |
if (temp.contains(period)) { | |
while (temp.isNotEmpty && temp[0] == zero) { | |
temp.removeAt(0); | |
} | |
if (temp.isEmpty) { | |
return zero; | |
} else if (temp[0] == period) { | |
temp.insert(0, zero); | |
} | |
} else { | |
if (!allowAllZeroes) { | |
while (temp.length > 1) { | |
if (temp.first == zero) { | |
temp.removeAt(0); | |
} else { | |
break; | |
} | |
} | |
} | |
} | |
return temp.join(); | |
} | |
String toCurrencyString( | |
String value, { | |
int mantissaLength = 2, | |
}) { | |
bool isNegative = false; | |
if (value.startsWith('-')) { | |
value = value.replaceAll(RegExp(r'^[-+]+'), ''); | |
isNegative = true; | |
} | |
value = value.replaceAll(_spaceRegex, ''); | |
if (value.isEmpty) { | |
value = '0'; | |
} | |
String mSeparator = _getMantissaSeparator(mantissaLength); | |
String tSeparator = _getThousandSeparator(); | |
value = toNumericString( | |
value, | |
allowAllZeroes: false, | |
allowHyphen: true, | |
allowPeriod: true, | |
mantissaSeparator: mSeparator, | |
); | |
String? fractionalSeparator = | |
mantissaLength > 0 ? _detectFractionSeparator(value) : null; | |
var sb = StringBuffer(); | |
bool addedMantissaSeparator = false; | |
for (var i = 0; i < value.length; i++) { | |
final char = value[i]; | |
if (char == '-') { | |
if (i > 0) { | |
continue; | |
} | |
sb.write(char); | |
} | |
if (isDigit(char, positiveOnly: true)) { | |
sb.write(char); | |
} | |
if (char == fractionalSeparator) { | |
if (!addedMantissaSeparator) { | |
sb.write('.'); | |
addedMantissaSeparator = true; | |
} else { | |
continue; | |
} | |
} | |
} | |
final str = sb.toString(); | |
final evenPart = | |
addedMantissaSeparator ? str.substring(0, str.indexOf('.')) : str; | |
int skipEvenNumbers = 0; | |
String shorteningName = ''; | |
bool ignoreMantissa = skipEvenNumbers > 0; | |
final fractionalPart = | |
addedMantissaSeparator ? str.substring(str.indexOf('.') + 1) : ''; | |
final reversed = evenPart.split('').reversed.toList(); | |
List<String> temp = []; | |
bool skippedLast = false; | |
for (var i = 0; i < reversed.length; i++) { | |
final char = reversed[i]; | |
if (skipEvenNumbers > 0) { | |
skipEvenNumbers--; | |
skippedLast = true; | |
continue; | |
} | |
if (i > 0) { | |
if (i % 3 == 0) { | |
if (!skippedLast) { | |
temp.add(tSeparator); | |
} | |
} | |
} | |
skippedLast = false; | |
temp.add(char); | |
} | |
value = temp.reversed.join(''); | |
sb = StringBuffer(); | |
for (var i = 0; i < mantissaLength; i++) { | |
if (i < fractionalPart.length) { | |
sb.write(fractionalPart[i]); | |
} else { | |
sb.write('0'); | |
} | |
} | |
final fraction = sb.toString(); | |
if (value.isEmpty) { | |
value = '0'; | |
} | |
if (ignoreMantissa) { | |
value = '$value$shorteningName'; | |
} else { | |
value = '$value$mSeparator$fraction'; | |
} | |
/// add leading and trailing | |
sb = StringBuffer(); | |
for (var i = 0; i < value.length; i++) { | |
sb.write(value[i]); | |
} | |
value = sb.toString(); | |
if (isNegative) { | |
return '-$value'; | |
} | |
return value; | |
} | |
String _getMantissaSeparator( | |
int mantissaLength, | |
) { | |
if (mantissaLength < 1) { | |
return ''; | |
} | |
return '.'; | |
} | |
String _getThousandSeparator() { | |
return ','; | |
} | |
final RegExp _possibleFractionRegExp = RegExp(r'[,.]'); | |
String? _detectFractionSeparator(String value) { | |
final index = value.lastIndexOf(_possibleFractionRegExp); | |
if (index < 0) { | |
return null; | |
} | |
final separator = value[index]; | |
int numOccurrences = 0; | |
for (var i = 0; i < value.length; i++) { | |
final char = value[i]; | |
if (char == separator) { | |
numOccurrences++; | |
} | |
} | |
if (numOccurrences == 1) { | |
return separator; | |
} | |
return null; | |
} | |
bool isDigit( | |
String? character, { | |
bool positiveOnly = false, | |
}) { | |
if (character == null || character.isEmpty || character.length > 1) { | |
return false; | |
} | |
if (positiveOnly) { | |
return _positiveDigitRegExp.stringMatch(character) != null; | |
} | |
return _digitRegExp.stringMatch(character) != null; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment