Skip to content

Instantly share code, notes, and snippets.

@CoderNamedHendrick
Created March 24, 2023 13:05
Show Gist options
  • Save CoderNamedHendrick/772667595664ab57e4e03a3e320e51de to your computer and use it in GitHub Desktop.
Save CoderNamedHendrick/772667595664ab57e4e03a3e320e51de to your computer and use it in GitHub Desktop.
Amount input formatter with decimal entry
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