diff --git a/example/pubspec.lock b/example/pubspec.lock index 6a17161..48218a1 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -60,10 +60,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -86,26 +86,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -195,18 +195,18 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -216,5 +216,5 @@ packages: source: hosted version: "14.3.1" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/amount_input_formatter.dart b/lib/src/amount_input_formatter.dart index 0fdc2ca..49301d4 100644 --- a/lib/src/amount_input_formatter.dart +++ b/lib/src/amount_input_formatter.dart @@ -82,9 +82,38 @@ class AmountInputFormatter extends TextInputFormatter { required TextEditingValue newValue, required String newText, }) { + // Special case: Handle when user types "-" on zero value + // The formatted value will be "-0" (or "-0.000"), cursor should be after "-0" + final oldTextStartsWithMinus = oldValue.text.startsWith('-'); + final newTextStartsWithMinus = newText.startsWith('-'); + if (formatter.doubleValue == 0 && + newTextStartsWithMinus && + !oldTextStartsWithMinus && + (oldValue.text == '0' || + oldValue.text.isEmpty || + oldValue.text.replaceAll(RegExp('[^0-9.]'), '').replaceAll('.', '') == '0')) { + // User typed "-" on zero, position cursor after "-0" (at indexOfDot) + return formatter.indexOfDot; + } + + // Special case: Handle when user types a digit after "-0" + // Transition from "-0" to negative number (e.g., "-1") + // Check if we're transitioning from zero to a small negative number + final isTypingDigitAfterNegativeZero = formatter.previousValue == 0 && + formatter.doubleValue < 0 && + oldTextStartsWithMinus && + newTextStartsWithMinus && + formatter.doubleValue.abs() <= 9; + + if (isTypingDigitAfterNegativeZero) { + // User typed a digit after "-0", position cursor after the digit (at indexOfDot) + // This prevents cursor from jumping to decimal separator + return formatter.indexOfDot; + } + // Assuming that it is the start of the input set the selection to the end // of the integer part. - if (oldValue.selection.baseOffset <= 1 && formatter.doubleValue <= 9) { + if (oldValue.selection.baseOffset <= 1 && formatter.doubleValue.abs() <= 9) { if (newText.isEmpty) return 0; if (formatter.doubleValue == 0) { @@ -100,22 +129,26 @@ class AmountInputFormatter extends TextInputFormatter { // Assuming that it is the start of the input set the selection to the // end of the integer part. + // Handle small numbers (including negative) when cursor is at start if (oldValue.selection.baseOffset <= 1 && - formatter.doubleValue <= 9 && - formatter.previousValue <= 9) { + formatter.doubleValue.abs() <= 9 && + formatter.previousValue.abs() <= 9) { return formatter.indexOfDot; } // Case if the overall text length didn't change // (i.e. one character replacement). if (oldValue.text.length == newText.length) { + // Special case: If we're typing a digit after "-0", use indexOfDot instead of newSelection + if (isTypingDigitAfterNegativeZero) { + return formatter.indexOfDot; + } + final oldSelection = oldValue.selection; final newSelection = newValue.selection; if (newSelection.baseOffset > oldSelection.baseOffset) { - return newSelection.baseOffset > newText.length - ? newText.length - : newSelection.baseOffset; + return newSelection.baseOffset > newText.length ? newText.length : newSelection.baseOffset; } return newSelection.baseOffset; @@ -126,15 +159,13 @@ class AmountInputFormatter extends TextInputFormatter { if (newText.length < oldValue.text.length) { offset = oldValue.text.length - newText.length > 1 - ? oldValue.selection.baseOffset - - (oldValue.text.length - newText.length) + ? oldValue.selection.baseOffset - (oldValue.text.length - newText.length) : oldValue.selection.baseOffset - 1; return offset < 0 ? 0 : offset; } offset = newText.length - oldValue.text.length > 1 - ? oldValue.selection.baseOffset + - (newText.length - oldValue.text.length) + ? oldValue.selection.baseOffset + (newText.length - oldValue.text.length) : oldValue.selection.baseOffset + 1; return offset > newText.length ? newText.length - 1 : offset; @@ -145,6 +176,108 @@ class AmountInputFormatter extends TextInputFormatter { TextEditingValue oldValue, TextEditingValue newValue, ) { + // Special case: Handle deletion right after decimal separator + // When cursor is right after the decimal separator and user hits delete, + // we should remove the digit before the decimal separator instead of removing the separator itself + final decimalSeparator = formatter.dcSeparator; + final oldText = oldValue.text; + final oldSelection = oldValue.selection; + final newTextInput = newValue.text; + + // Check if we're deleting (text got shorter) + final isDeleting = newTextInput.length < oldText.length; + + // Check if old text has decimal separator + final oldHasDecimalSeparator = oldText.contains(decimalSeparator); + + // Check if cursor was right after decimal separator in old value + // We need to find the position of decimal separator in old text + // Use lastIndexOf in case there are multiple occurrences (unlikely but possible) + final decimalSeparatorIndex = oldHasDecimalSeparator ? oldText.lastIndexOf(decimalSeparator) : -1; + // Check if cursor is right after the decimal separator (delete key deletes the character before cursor) + final oldCursorOffset = oldSelection.baseOffset; + final cursorWasAfterDecimalSeparator = decimalSeparatorIndex >= 0 && + oldCursorOffset == decimalSeparatorIndex + 1; + + // Check if new text doesn't have decimal separator but old text did + final newHasDecimalSeparator = newTextInput.contains(decimalSeparator); + final decimalSeparatorWasRemoved = oldHasDecimalSeparator && !newHasDecimalSeparator; + + // If all conditions are met, we need to modify the deletion behavior + if (isDeleting && cursorWasAfterDecimalSeparator && decimalSeparatorWasRemoved) { + // Instead of removing the decimal separator, we should remove the last digit + // from the integer part before the decimal separator + // Work directly with the old text string to preserve exact digits + final groupSeparator = formatter.intSeparator; + + // Find the decimal separator position in the old text + if (decimalSeparatorIndex > 0) { + // Extract the integer part (everything before the decimal separator) + // Remove group separators to get just the digits + final integerPartWithSeparators = oldText.substring(0, decimalSeparatorIndex); + final integerPartDigits = integerPartWithSeparators.replaceAll(groupSeparator, '').replaceAll(RegExp(r'[^\d\-]'), ''); + + // Check if we have a negative sign + final isNegative = integerPartDigits.startsWith('-'); + final integerDigitsOnly = isNegative ? integerPartDigits.substring(1) : integerPartDigits; + + // Remove the last digit from the integer part + if (integerDigitsOnly.length > 1) { + final newIntegerDigits = integerDigitsOnly.substring(0, integerDigitsOnly.length - 1); + + // Get the decimal part from the old text (everything after the decimal separator) + final decimalPart = oldText.substring(decimalSeparatorIndex + 1); + // Remove any non-digit characters from decimal part (like group separators, though unlikely) + final decimalDigitsOnly = decimalPart.replaceAll(RegExp(r'[^\d]'), ''); + + // Reconstruct the number string with the sign + final newNumericString = isNegative + ? '-$newIntegerDigits$decimalSeparator$decimalDigitsOnly' + : '$newIntegerDigits$decimalSeparator$decimalDigitsOnly'; + + // Process the modified text + final processedText = formatter.processTextValue( + textInput: newNumericString, + ); + + if (processedText == null) return oldValue; + + // Calculate cursor position - should be before the decimal separator + final newDecimalSeparatorIndex = processedText.indexOf(decimalSeparator); + final cursorOffset = newDecimalSeparatorIndex >= 0 ? newDecimalSeparatorIndex : processedText.length; + + return TextEditingValue( + text: processedText, + selection: TextSelection.collapsed(offset: cursorOffset), + ); + } else if (integerDigitsOnly.length == 1 && integerDigitsOnly != '0') { + // If integer part has only one non-zero digit, removing it makes it "0" + final decimalPart = oldText.substring(decimalSeparatorIndex + 1); + final decimalDigitsOnly = decimalPart.replaceAll(RegExp(r'[^\d]'), ''); + + final newNumericString = isNegative + ? '-0$decimalSeparator$decimalDigitsOnly' + : '0$decimalSeparator$decimalDigitsOnly'; + + final processedText = formatter.processTextValue( + textInput: newNumericString, + ); + + if (processedText == null) return oldValue; + + final newDecimalSeparatorIndex = processedText.indexOf(decimalSeparator); + final cursorOffset = newDecimalSeparatorIndex >= 0 ? newDecimalSeparatorIndex : processedText.length; + + return TextEditingValue( + text: processedText, + selection: TextSelection.collapsed(offset: cursorOffset), + ); + } + // If integer part is "0", we can't remove a digit, so just position cursor before decimal separator + // This case will fall through to normal processing, but cursor positioning should be handled + } + } + final newText = formatter.processTextValue( textInput: newValue.text, ); @@ -179,10 +312,11 @@ class AmountInputFormatter extends TextInputFormatter { num number, { TextEditingController? attachedController, }) { - if (attachedController == null) return formatter.setNumValue(number); + final formattedText = formatter.setNumValue(number); + if (attachedController == null) return formattedText; attachedController.value = TextEditingValue( - text: formatter.setNumValue(number), + text: formattedText, selection: TextSelection.collapsed(offset: formatter.indexOfDot), ); diff --git a/lib/src/number_formatter.dart b/lib/src/number_formatter.dart index a9cd516..076975b 100644 --- a/lib/src/number_formatter.dart +++ b/lib/src/number_formatter.dart @@ -56,16 +56,9 @@ class NumberFormatter { fractionalDigits: fractionalDigits, initialValue: initialValue.toDouble(), isEmptyAllowed: isEmptyAllowed, - initialFormattedValue: '${_processIntegerPart( - integerPart: doubleParts.first, - thSeparator: groupSeparator, - intSpDigits: groupedDigits, - )}' - '${_processDecimalPart( - decimalPart: doubleParts.last, - ftlDigits: fractionalDigits, - dcSeparator: decimalSeparator, - )}', + initialFormattedValue: + '${_processIntegerPart(integerPart: doubleParts.first, thSeparator: groupSeparator, intSpDigits: groupedDigits)}' + '${_processDecimalPart(decimalPart: doubleParts.last, ftlDigits: fractionalDigits, dcSeparator: decimalSeparator)}', indexOfDot: doubleParts.first.length, ); } @@ -92,7 +85,7 @@ class NumberFormatter { _currentValue = initialValue ?? 0, _indexOfDot = indexOfDot; - /// Default setting options for the formatter. + /// Default settings options for the formatter. NumberFormatter.defaultSettings() : _intLthLimiter = kIntegralLengthLimit, _intSeparator = kComma, @@ -102,7 +95,7 @@ class NumberFormatter { _formattedNum = kEmptyValue, _currentValue = 0, _indexOfDot = -1, - _numPattern = RegExp('[^0-9$kDot]'), + _numPattern = RegExp('[^0-9$kDot-]'), _isEmptyAllowed = false; /// Unicode "Left-To-Right Embedding" (LRE) character \u202A. @@ -264,20 +257,27 @@ class NumberFormatter { required String thSeparator, required int intSpDigits, }) { - if (integerPart.length < intSpDigits) return integerPart; + // Check if the integer part starts with a negative sign + final isNegative = integerPart.startsWith('-'); + final digitsOnly = isNegative ? integerPart.substring(1) : integerPart; + + if (digitsOnly.length < intSpDigits) { + return isNegative ? '-$digitsOnly' : digitsOnly; + } final intBuffer = StringBuffer(); - for (var i = 1; i <= integerPart.length; i++) { - intBuffer.write(integerPart[integerPart.length - i]); + for (var i = 1; i <= digitsOnly.length; i++) { + intBuffer.write(digitsOnly[digitsOnly.length - i]); - if (i % intSpDigits == 0 && i != integerPart.length) { + if (i % intSpDigits == 0 && i != digitsOnly.length) { intBuffer.write(thSeparator); } } // As the writes to buffer was made in reversed order it should // be reversed back. - return String.fromCharCodes(intBuffer.toString().codeUnits.reversed); + final result = String.fromCharCodes(intBuffer.toString().codeUnits.reversed); + return isNegative ? '-$result' : result; } /// This method should be used to process the decimal part of the @@ -311,21 +311,18 @@ class NumberFormatter { } _doubleValue = inputNumber; + final isNegative = inputNumber < 0; doubleParts ??= inputNumber.abs().toString().split(kDot); + // Prepend negative sign if the number is negative + final integerPartWithSign = isNegative ? '-${doubleParts.first}' : doubleParts.first; + // Set the index of dot to the length of the integral part of the number. - _indexOfDot = doubleParts.first.length; - - return _formattedNum = '${_processIntegerPart( - integerPart: doubleParts.first, - thSeparator: intSeparator, - intSpDigits: intSpDigits, - )}' - '${_processDecimalPart( - decimalPart: doubleParts.last, - ftlDigits: ftlDigits, - dcSeparator: dcSeparator, - )}'; + _indexOfDot = integerPartWithSign.length; + + return _formattedNum = + '${_processIntegerPart(integerPart: integerPartWithSign, thSeparator: intSeparator, intSpDigits: intSpDigits)}' + '${_processDecimalPart(decimalPart: doubleParts.last, ftlDigits: ftlDigits, dcSeparator: dcSeparator)}'; } String _processEmptyValue({ @@ -352,19 +349,46 @@ class NumberFormatter { required String textInput, }) { // Case when text input is deleted completely or is initially empty. + // But if user types "-" on empty field, we want to show "-0" not empty if (textInput.isEmpty) { - return _processEmptyValue( - textInput: textInput, - isEmptyAllowed: isEmptyAllowed, + // If previous value was -0.0 and user deleted everything, show "-0" instead of empty + if (_currentValue == 0 && _formattedNum.startsWith('-')) { + // User deleted text but we want to preserve the negative sign context + // Actually, if text is empty, we should process as empty + return _processEmptyValue(textInput: textInput, isEmptyAllowed: isEmptyAllowed); + } + return _processEmptyValue(textInput: textInput, isEmptyAllowed: isEmptyAllowed); + } + + // Check if the input starts with a negative sign + // Handle multiple negative signs by only considering the first one + final isNegative = textInput.startsWith('-'); + String textWithoutSign = isNegative ? textInput.substring(1) : textInput; + // Remove any additional negative signs that might have been typed + if (isNegative && textWithoutSign.startsWith('-')) { + // User typed multiple negative signs, remove them + textWithoutSign = textWithoutSign.replaceAll('-', ''); + } + + // Special early check: If input is just "-" (after cleaning), always show "-0" + // This handles cases where user selects all and types "-", or types "-" on "-0" + // Case 1: User selects all text and types "-" -> should show "-0" not "0." + // Case 2: User types "-" again on "-0" -> should show "-0" not empty + if (isNegative && textWithoutSign.isEmpty) { + const integerPartWithSign = '-0'; + final formattedIntegerPart = _processIntegerPart( + integerPart: integerPartWithSign, + thSeparator: intSeparator, + intSpDigits: intSpDigits, ); + _indexOfDot = formattedIntegerPart.length; + _doubleValue = 0; + _previousValue = _currentValue; + return _formattedNum = '$formattedIntegerPart' + '${_processDecimalPart(decimalPart: kEmptyValue, ftlDigits: ftlDigits, dcSeparator: dcSeparator)}'; } - final doubleParts = textInput - .replaceAll( - _numPattern, - kEmptyValue, - ) - .split(dcSeparator); + final doubleParts = textWithoutSign.replaceAll(_numPattern, kEmptyValue).split(dcSeparator); // In case if there is no decimal part in the provided string // representation of number. @@ -374,9 +398,7 @@ class NumberFormatter { // It might be the case that the user deleted the decimal point or part of // the input with a decimal point was deleted with the selection range. // In this case, the decimal part should be zeroed. - if (ftlDigits > 0 && - _indexOfDot > 0 && - _indexOfDot < doubleParts.first.length) { + if (ftlDigits > 0 && _indexOfDot > 0 && _indexOfDot < doubleParts.first.length) { doubleParts.first = doubleParts.first.substring(0, _indexOfDot); } } else if (doubleParts.last.length > ftlDigits) { @@ -389,8 +411,7 @@ class NumberFormatter { // Checks if the integer part is empty, and sets the value to '0' if true. if (doubleParts.first.isEmpty) { doubleParts.first = kZeroValue; - } else if (doubleParts.first[0] == kZeroValue && - doubleParts.first.length > 1) { + } else if (doubleParts.first[0] == kZeroValue && doubleParts.first.length > 1) { var index = -1; for (var i = 0; i < doubleParts.first.length; i++) { @@ -406,12 +427,69 @@ class NumberFormatter { } } - return _processNumberValue( - inputNumber: double.tryParse( - '${doubleParts.first}$kDot${doubleParts.last}', - ), - doubleParts: doubleParts, - ); + // Prepend negative sign if input was negative + final numericString = '${doubleParts.first}$kDot${doubleParts.last}'; + final signedNumericString = isNegative ? '-$numericString' : numericString; + + // Parse the number to check if it's zero (including -0.0) + final parsedNumber = double.tryParse(signedNumericString); + final parsedIsZero = parsedNumber != null && parsedNumber == 0.0; + + // Special case: Allow "-" to be typed when value is zero, so user can start typing negative numbers + // Check if input is just "-" or "-" with only zeros + final integerPartIsZero = doubleParts.first == kZeroValue || doubleParts.first.isEmpty; + // Check if decimal part is empty or contains only zeros (after removing non-digits) + final decimalDigitsOnly = doubleParts.last.replaceAll(RegExp('[^0-9]'), kEmptyValue); + final decimalPartEmptyOrZero = doubleParts.last.isEmpty || + (decimalDigitsOnly.isNotEmpty && + decimalDigitsOnly.split('').every((char) => char == kZeroValue)); + final isOnlyNegativeSign = isNegative && integerPartIsZero && decimalPartEmptyOrZero; + + // Handle special case: if user typed "-" resulting in zero (including -0.0), format as "-0" to allow typing negative numbers + // This handles: + // 1. User selects all and types "-" (input is just "-") - always show "-0" regardless of previous value + // 2. User types "-" on empty field or zero value + // 3. User types "-" when value is already -0.0 - keep as "-0" instead of becoming empty + if (isOnlyNegativeSign && parsedIsZero) { + final integerPartWithSign = '-${doubleParts.first}'; + // Format the integer part to get the actual formatted string (may include group separators) + final formattedIntegerPart = _processIntegerPart( + integerPart: integerPartWithSign, + thSeparator: intSeparator, + intSpDigits: intSpDigits, + ); + // Set indexOfDot to the position after the formatted integer part + // This ensures cursor is positioned correctly after "-0" (before decimal separator if exists) + _indexOfDot = formattedIntegerPart.length; + _doubleValue = 0; // Keep value as 0, but display with negative sign + _previousValue = _currentValue; // Update previous value for cursor calculation + return _formattedNum = '$formattedIntegerPart' + '${_processDecimalPart(decimalPart: doubleParts.last, ftlDigits: ftlDigits, dcSeparator: dcSeparator)}'; + } + + // If parsed number is null (invalid), return null to reject the input + if (parsedNumber == null) { + return null; + } + + // Special handling: If input was "-" and result is 0 (including -0.0), + // we want to show "-0" not "0" + // This handles case 1: user selects all and types "-" + if (isNegative && parsedNumber == 0.0 && integerPartIsZero && decimalPartEmptyOrZero) { + final integerPartWithSign = '-${doubleParts.first}'; + final formattedIntegerPart = _processIntegerPart( + integerPart: integerPartWithSign, + thSeparator: intSeparator, + intSpDigits: intSpDigits, + ); + _indexOfDot = formattedIntegerPart.length; + _doubleValue = 0; + _previousValue = _currentValue; + return _formattedNum = '$formattedIntegerPart' + '${_processDecimalPart(decimalPart: doubleParts.last, ftlDigits: ftlDigits, dcSeparator: dcSeparator)}'; + } + + return _processNumberValue(inputNumber: parsedNumber, doubleParts: doubleParts); } /// This method will process and format the given numerical value through the diff --git a/lib/src/text_controller_extension.dart b/lib/src/text_controller_extension.dart index d95284d..6c9babd 100644 --- a/lib/src/text_controller_extension.dart +++ b/lib/src/text_controller_extension.dart @@ -12,10 +12,7 @@ extension FormatterTextControllerExtension on TextEditingController { required AmountInputFormatter formatter, TextEditingValue? oldValue, }) { - value = formatter.formatEditUpdate( - oldValue ?? value, - TextEditingValue(text: text), - ); + value = formatter.formatEditUpdate(oldValue ?? value, TextEditingValue(text: text)); return this.text; } @@ -25,9 +22,7 @@ extension FormatterTextControllerExtension on TextEditingController { String syncWithFormatter({required AmountInputFormatter formatter}) { value = TextEditingValue( text: formatter.formattedValue, - selection: TextSelection.collapsed( - offset: formatter.formatter.indexOfDot, - ), + selection: TextSelection.collapsed(offset: formatter.formatter.indexOfDot), ); return text;