From dcb611f23735f20a86c4fdfcdcd835f2a2190389 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 10:48:32 +0300 Subject: [PATCH 1/7] Exposed interface tests --- example/pubspec.lock | 2 +- test/signature_test.dart | 171 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 test/signature_test.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index d1510a7..9f1165d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -145,7 +145,7 @@ packages: path: ".." relative: true source: path - version: "1.4.0" + version: "2.0.0" path: dependency: transitive description: diff --git a/test/signature_test.dart b/test/signature_test.dart new file mode 100644 index 0000000..41763d5 --- /dev/null +++ b/test/signature_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:number_editing_controller/number_editing_controller.dart'; + +/// Tests that verify the public API surface of NumberEditingTextController. +/// These must continue to pass after internal refactoring to guarantee +/// that no existing consumer code breaks. +void main() { + group('Public API signature', () { + group('NumberEditingTextController extends TextEditingController', () { + test('is a TextEditingController', () { + final controller = NumberEditingTextController.integer(); + expect(controller, isA()); + }); + + test('is a ChangeNotifier', () { + final controller = NumberEditingTextController.integer(); + expect(controller, isA()); + }); + }); + + group('.currency constructor', () { + test('accepts all named parameters', () { + final controller = NumberEditingTextController.currency( + locale: 'en', + currencyName: 'USD', + currencySymbol: '\$', + value: 1.5, + decimalSeparator: '.', + groupSeparator: ',', + allowNegative: false, + ); + expect(controller, isA()); + }); + + test('all parameters are optional', () { + final controller = NumberEditingTextController.currency(); + expect(controller, isA()); + }); + }); + + group('.decimal constructor', () { + test('accepts all named parameters', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 2, + maximumFractionDigits: 4, + value: 3.14, + decimalSeparator: '.', + groupSeparator: ',', + allowNegative: false, + ); + expect(controller, isA()); + }); + + test('all parameters are optional', () { + final controller = NumberEditingTextController.decimal(); + expect(controller, isA()); + }); + }); + + group('.integer constructor', () { + test('accepts all named parameters', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 42, + groupSeparator: ',', + allowNegative: false, + ); + expect(controller, isA()); + }); + + test('all parameters are optional', () { + final controller = NumberEditingTextController.integer(); + expect(controller, isA()); + }); + }); + + group('number getter/setter', () { + test('getter returns num?', () { + final controller = NumberEditingTextController.integer(); + final num? n = controller.number; + expect(n, isNull); + }); + + test('setter accepts num?', () { + final controller = NumberEditingTextController.integer(); + controller.number = 42; + expect(controller.number, 42); + + controller.number = 3.14; + expect(controller.number, 3.14); + + controller.number = null; + expect(controller.number, isNull); + }); + }); + + group('value getter/setter (inherited)', () { + test('getter returns TextEditingValue', () { + final controller = NumberEditingTextController.integer(); + expect(controller.value, isA()); + }); + + test('setter accepts TextEditingValue and triggers formatting', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = const TextEditingValue( + text: '1234', + selection: TextSelection.collapsed(offset: 4), + ); + expect(controller.value.text, '1,234'); + expect(controller.number, 1234); + }); + }); + + group('text property (inherited)', () { + test('returns formatted string', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 1000, + ); + expect(controller.text, isA()); + expect(controller.text, '1,000'); + }); + }); + + group('addListener/removeListener (inherited)', () { + test('notifies listeners on value change', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + var called = false; + void listener() => called = true; + controller.addListener(listener); + controller.number = 5; + expect(called, isTrue); + controller.removeListener(listener); + }); + }); + + group('constructor parameter defaults', () { + test('currency defaults to allowNegative=true', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = const TextEditingValue( + text: '-5', + selection: TextSelection.collapsed(offset: 2), + ); + expect(controller.number, -5); + }); + + test('decimal defaults to allowNegative=true', () { + final controller = NumberEditingTextController.decimal(locale: 'en'); + controller.value = const TextEditingValue( + text: '-3.5', + selection: TextSelection.collapsed(offset: 4), + ); + expect(controller.number, -3.5); + }); + + test('integer defaults to allowNegative=true', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = const TextEditingValue( + text: '-7', + selection: TextSelection.collapsed(offset: 2), + ); + expect(controller.number, -7); + }); + }); + }); +} From 2db3598135c4401200e8747df203d94989b8d959 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 10:54:51 +0300 Subject: [PATCH 2/7] Allow mutable format options on controller Add getters/setters for locale, groupSeparator, and allowNegative. Rebuild the internal ParsedNumberFormat and reformat the current value when these options change. Ensure number setter respects allowNegative. Add tests covering locale, separator, and allowNegative mutations. --- lib/src/text_controller.dart | 130 ++++++++++++++++++- test/signature_test.dart | 244 +++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 6 deletions(-) diff --git a/lib/src/text_controller.dart b/lib/src/text_controller.dart index e74073a..8ec27a3 100644 --- a/lib/src/text_controller.dart +++ b/lib/src/text_controller.dart @@ -11,11 +11,31 @@ import 'package:number_editing_controller/src/parsed_number_format.dart'; /// /// The formatted text is displayed in the [TextField], while the underlying /// numeric value can be accessed via the [number] property. +/// +/// Formatting options such as [locale], [groupSeparator], and [allowNegative] +/// can be changed at any time. Changing an option automatically reformats the +/// current value. class NumberEditingTextController extends TextEditingController { - final ParsedNumberFormat _format; + ParsedNumberFormat _format; num? _number; + String? _locale; + String? _groupSeparator; + bool _allowNegative; + + // Currency-specific fields. + String? _currencyName; + String? _currencySymbol; + String? _decimalSeparator; + + // Decimal-specific fields. + int? _minimalFractionDigits; + int? _maximumFractionDigits; + + // Tracks which constructor was used so we can rebuild the format. + final _FormatType _formatType; + /// Creates a controller instance suitable for formatting input as a currency amount. /// /// [locale] - locale to be used for number formatting, defaults to [Intl.getCurrentLocale()] @@ -33,7 +53,16 @@ class NumberEditingTextController extends TextEditingController { String? decimalSeparator, String? groupSeparator, bool allowNegative = true, - }) : _format = ParsedNumberFormat.currency( + }) : _locale = locale, + _currencyName = currencyName, + _currencySymbol = currencySymbol, + _decimalSeparator = decimalSeparator, + _groupSeparator = groupSeparator, + _allowNegative = allowNegative, + _minimalFractionDigits = null, + _maximumFractionDigits = null, + _formatType = _FormatType.currency, + _format = ParsedNumberFormat.currency( locale: locale, currencyName: currencyName, currencySymbol: currencySymbol, @@ -61,7 +90,16 @@ class NumberEditingTextController extends TextEditingController { String? decimalSeparator, String? groupSeparator, bool allowNegative = true, - }) : _format = ParsedNumberFormat.decimal( + }) : _locale = locale, + _minimalFractionDigits = minimalFractionDigits, + _maximumFractionDigits = maximumFractionDigits, + _decimalSeparator = decimalSeparator, + _groupSeparator = groupSeparator, + _allowNegative = allowNegative, + _currencyName = null, + _currencySymbol = null, + _formatType = _FormatType.decimal, + _format = ParsedNumberFormat.decimal( locale: locale, minimalFractionDigits: minimalFractionDigits, maximumFractionDigits: maximumFractionDigits, @@ -83,7 +121,16 @@ class NumberEditingTextController extends TextEditingController { num? value, String? groupSeparator, bool allowNegative = true, - }) : _format = ParsedNumberFormat.integer( + }) : _locale = locale, + _groupSeparator = groupSeparator, + _allowNegative = allowNegative, + _currencyName = null, + _currencySymbol = null, + _decimalSeparator = null, + _minimalFractionDigits = null, + _maximumFractionDigits = null, + _formatType = _FormatType.integer, + _format = ParsedNumberFormat.integer( locale: locale, groupSeparator: groupSeparator, allowNegative: allowNegative, @@ -91,6 +138,36 @@ class NumberEditingTextController extends TextEditingController { number = value; } + /// The locale used for number formatting. + /// + /// Setting this rebuilds the format and reformats the current value. + String? get locale => _locale; + set locale(String? value) { + if (_locale == value) return; + _locale = value; + _rebuildFormat(); + } + + /// The symbol used to group digits (e.g. `,` in `1,000`). + /// + /// Setting this rebuilds the format and reformats the current value. + String? get groupSeparator => _groupSeparator; + set groupSeparator(String? value) { + if (_groupSeparator == value) return; + _groupSeparator = value; + _rebuildFormat(); + } + + /// Whether negative number input is allowed. + /// + /// Setting this rebuilds the format and reformats the current value. + bool get allowNegative => _allowNegative; + set allowNegative(bool value) { + if (_allowNegative == value) return; + _allowNegative = value; + _rebuildFormat(); + } + /// The underlying numeric value extracted from the formatted text. /// /// Returns `null` when the text field is empty or contains no valid number. @@ -100,8 +177,11 @@ class NumberEditingTextController extends TextEditingController { /// /// Setting to `null` clears the text field. set number(num? number) { - _number = number; - final text = number == null ? '' : _format.formatString(number); + final effectiveNumber = + number != null && !_allowNegative && number < 0 ? -number : number; + _number = effectiveNumber; + final text = + effectiveNumber == null ? '' : _format.formatString(effectiveNumber); super.value = value.copyWith( text: text, selection: const TextSelection.collapsed(offset: -1), @@ -115,4 +195,42 @@ class NumberEditingTextController extends TextEditingController { _number = result.number; super.value = result.value; } + + void _rebuildFormat() { + _format = switch (_formatType) { + _FormatType.currency => ParsedNumberFormat.currency( + locale: _locale, + currencyName: _currencyName, + currencySymbol: _currencySymbol, + decimalSeparator: _decimalSeparator, + groupSeparator: _groupSeparator, + allowNegative: _allowNegative, + ), + _FormatType.decimal => ParsedNumberFormat.decimal( + locale: _locale, + minimalFractionDigits: _minimalFractionDigits, + maximumFractionDigits: _maximumFractionDigits, + decimalSeparator: _decimalSeparator, + groupSeparator: _groupSeparator, + allowNegative: _allowNegative, + ), + _FormatType.integer => ParsedNumberFormat.integer( + locale: _locale, + groupSeparator: _groupSeparator, + allowNegative: _allowNegative, + ), + }; + _reformat(); + } + + void _reformat() { + if (_number != null) { + // Re-set via the number setter so the new format can alter the value + // (e.g. stripping the sign when allowNegative becomes false). + final currentNumber = _number!; + number = currentNumber; + } + } } + +enum _FormatType { currency, decimal, integer } diff --git a/test/signature_test.dart b/test/signature_test.dart index 41763d5..944bdcb 100644 --- a/test/signature_test.dart +++ b/test/signature_test.dart @@ -136,6 +136,250 @@ void main() { }); }); + group('mutable locale', () { + test('changing locale reformats integer', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 1234567, + ); + expect(controller.text, '1,234,567'); + controller.locale = 'de'; + expect(controller.text, '1.234.567'); + expect(controller.number, 1234567); + }); + + test('changing locale reformats currency', () { + final controller = NumberEditingTextController.currency( + locale: 'en', + currencyName: 'EUR', + value: 1234.56, + ); + expect(controller.text, '€1,234.56'); + controller.locale = 'de'; + expect(controller.text, '1.234,56\u00A0€'); + expect(controller.number, 1234.56); + }); + + test('changing locale reformats decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + value: 1234.5, + ); + expect(controller.text, '1,234.5'); + controller.locale = 'de'; + expect(controller.text, '1.234,5'); + expect(controller.number, 1234.5); + }); + + test('setting same locale does not notify listeners', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 100, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.locale = 'en'; + expect(callCount, 0); + }); + + test('changing locale with null value keeps null', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + expect(controller.number, isNull); + controller.locale = 'de'; + expect(controller.number, isNull); + expect(controller.text, ''); + }); + + test('getter returns current locale', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + expect(controller.locale, 'en'); + controller.locale = 'fr'; + expect(controller.locale, 'fr'); + }); + }); + + group('mutable groupSeparator', () { + test('changing group separator reformats integer', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 1234567, + ); + expect(controller.text, '1,234,567'); + controller.groupSeparator = ' '; + expect(controller.text, '1 234 567'); + expect(controller.number, 1234567); + }); + + test('changing group separator reformats currency', () { + final controller = NumberEditingTextController.currency( + locale: 'en', + currencyName: 'USD', + value: 5000, + ); + expect(controller.text, '\$5,000'); + controller.groupSeparator = '.'; + expect(controller.text, '\$5.000'); + expect(controller.number, 5000); + }); + + test('changing group separator reformats decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + value: 12345.67, + ); + expect(controller.text, '12,345.67'); + controller.groupSeparator = ' '; + expect(controller.text, '12 345.67'); + expect(controller.number, 12345.67); + }); + + test('setting same separator does not notify listeners', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + groupSeparator: ',', + value: 1000, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.groupSeparator = ','; + expect(callCount, 0); + }); + + test('getter returns current separator', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + groupSeparator: ',', + ); + expect(controller.groupSeparator, ','); + controller.groupSeparator = '.'; + expect(controller.groupSeparator, '.'); + }); + + test('changing separator with null value keeps null', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.groupSeparator = '.'; + expect(controller.number, isNull); + expect(controller.text, ''); + }); + }); + + group('mutable allowNegative', () { + test('disabling allowNegative drops sign from negative integer', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: -500, + ); + expect(controller.text, '-500'); + expect(controller.number, -500); + controller.allowNegative = false; + expect(controller.text, '500'); + expect(controller.number, 500); + }); + + test('disabling allowNegative drops sign from negative decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + value: -3.14, + ); + expect(controller.text, '-3.14'); + controller.allowNegative = false; + expect(controller.text, '3.14'); + expect(controller.number, 3.14); + }); + + test('disabling allowNegative drops sign from negative currency', () { + final controller = NumberEditingTextController.currency( + locale: 'en', + currencyName: 'USD', + value: -42, + ); + expect(controller.text, '\$-42'); + controller.allowNegative = false; + expect(controller.text, '\$42'); + expect(controller.number, 42); + }); + + test('enabling allowNegative does not add sign to positive', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + allowNegative: false, + value: 100, + ); + expect(controller.text, '100'); + controller.allowNegative = true; + expect(controller.text, '100'); + expect(controller.number, 100); + }); + + test('setting same value does not notify listeners', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 10, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.allowNegative = true; + expect(callCount, 0); + }); + + test('getter returns current value', () { + final controller = NumberEditingTextController.integer(); + expect(controller.allowNegative, isTrue); + controller.allowNegative = false; + expect(controller.allowNegative, isFalse); + }); + + test('disabling with null value keeps null', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.allowNegative = false; + expect(controller.number, isNull); + expect(controller.text, ''); + }); + }); + + group('multiple mutations combined', () { + test('changing locale then separator', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 1000000, + ); + expect(controller.text, '1,000,000'); + controller.locale = 'de'; + expect(controller.text, '1.000.000'); + controller.groupSeparator = ' '; + expect(controller.text, '1 000 000'); + expect(controller.number, 1000000); + }); + + test('changing separator then disabling negative', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: -5000, + ); + expect(controller.text, '-5,000'); + controller.groupSeparator = '.'; + expect(controller.text, '-5.000'); + controller.allowNegative = false; + expect(controller.text, '5.000'); + expect(controller.number, 5000); + }); + + test('typing after mutation uses new format', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 100, + ); + expect(controller.text, '100'); + controller.groupSeparator = '.'; + controller.value = const TextEditingValue( + text: '1000', + selection: TextSelection.collapsed(offset: 4), + ); + expect(controller.text, '1.000'); + expect(controller.number, 1000); + }); + }); + group('constructor parameter defaults', () { test('currency defaults to allowNegative=true', () { final controller = NumberEditingTextController.currency( From 3f481032720584a553b0d83e9af36c6680193b9a Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 11:07:42 +0300 Subject: [PATCH 3/7] Split NumberEditingTextController into subclasses --- lib/number_editing_controller.dart | 7 +- lib/src/text_controller.dart | 310 +++++++++++++++++------------ 2 files changed, 190 insertions(+), 127 deletions(-) diff --git a/lib/number_editing_controller.dart b/lib/number_editing_controller.dart index 8640996..48af3d4 100644 --- a/lib/number_editing_controller.dart +++ b/lib/number_editing_controller.dart @@ -2,4 +2,9 @@ /// as you type with locale support. library; -export 'src/text_controller.dart' show NumberEditingTextController; +export 'src/text_controller.dart' + show + NumberEditingTextController, + CurrencyEditingController, + DecimalEditingController, + IntegerEditingController; diff --git a/lib/src/text_controller.dart b/lib/src/text_controller.dart index 8ec27a3..5ca24be 100644 --- a/lib/src/text_controller.dart +++ b/lib/src/text_controller.dart @@ -4,38 +4,24 @@ import 'package:number_editing_controller/src/parsed_number_format.dart'; /// A [TextEditingController] that automatically formats user input as /// numbers, decimals, or currencies with locale-aware formatting. /// -/// Use one of the named constructors to create an instance: +/// Use one of the factory constructors to create an instance: /// - [NumberEditingTextController.currency] for currency amounts /// - [NumberEditingTextController.decimal] for decimal numbers /// - [NumberEditingTextController.integer] for integers /// +/// Or instantiate the subclasses directly: +/// - [CurrencyEditingController] +/// - [DecimalEditingController] +/// - [IntegerEditingController] +/// /// The formatted text is displayed in the [TextField], while the underlying /// numeric value can be accessed via the [number] property. /// /// Formatting options such as [locale], [groupSeparator], and [allowNegative] /// can be changed at any time. Changing an option automatically reformats the /// current value. -class NumberEditingTextController extends TextEditingController { - ParsedNumberFormat _format; - - num? _number; - - String? _locale; - String? _groupSeparator; - bool _allowNegative; - - // Currency-specific fields. - String? _currencyName; - String? _currencySymbol; - String? _decimalSeparator; - - // Decimal-specific fields. - int? _minimalFractionDigits; - int? _maximumFractionDigits; - - // Tracks which constructor was used so we can rebuild the format. - final _FormatType _formatType; - +abstract interface class NumberEditingTextController + implements TextEditingController { /// Creates a controller instance suitable for formatting input as a currency amount. /// /// [locale] - locale to be used for number formatting, defaults to [Intl.getCurrentLocale()] @@ -45,33 +31,15 @@ class NumberEditingTextController extends TextEditingController { /// [decimalSeparator] - symbol used to separate the decimal part /// [groupSeparator] - symbol used to group digits /// [allowNegative] - whether to allow negative number input - NumberEditingTextController.currency({ + factory NumberEditingTextController.currency({ String? locale, String? currencyName, String? currencySymbol, num? value, String? decimalSeparator, String? groupSeparator, - bool allowNegative = true, - }) : _locale = locale, - _currencyName = currencyName, - _currencySymbol = currencySymbol, - _decimalSeparator = decimalSeparator, - _groupSeparator = groupSeparator, - _allowNegative = allowNegative, - _minimalFractionDigits = null, - _maximumFractionDigits = null, - _formatType = _FormatType.currency, - _format = ParsedNumberFormat.currency( - locale: locale, - currencyName: currencyName, - currencySymbol: currencySymbol, - decimalSeparator: decimalSeparator, - groupSeparator: groupSeparator, - allowNegative: allowNegative, - ) { - number = value; - } + bool allowNegative, + }) = CurrencyEditingController; /// Creates a controller instance suitable for formatting input as a decimal number. /// @@ -82,33 +50,15 @@ class NumberEditingTextController extends TextEditingController { /// [decimalSeparator] - symbol used to separate the decimal part /// [groupSeparator] - symbol used to group digits /// [allowNegative] - whether to allow negative number input - NumberEditingTextController.decimal({ + factory NumberEditingTextController.decimal({ String? locale, int? minimalFractionDigits, int? maximumFractionDigits, num? value, String? decimalSeparator, String? groupSeparator, - bool allowNegative = true, - }) : _locale = locale, - _minimalFractionDigits = minimalFractionDigits, - _maximumFractionDigits = maximumFractionDigits, - _decimalSeparator = decimalSeparator, - _groupSeparator = groupSeparator, - _allowNegative = allowNegative, - _currencyName = null, - _currencySymbol = null, - _formatType = _FormatType.decimal, - _format = ParsedNumberFormat.decimal( - locale: locale, - minimalFractionDigits: minimalFractionDigits, - maximumFractionDigits: maximumFractionDigits, - decimalSeparator: decimalSeparator, - groupSeparator: groupSeparator, - allowNegative: allowNegative, - ) { - number = value; - } + bool allowNegative, + }) = DecimalEditingController; /// Creates a controller instance suitable for formatting input as an integer. /// @@ -116,66 +66,91 @@ class NumberEditingTextController extends TextEditingController { /// [value] - optional initial value /// [groupSeparator] - symbol used to group digits /// [allowNegative] - whether to allow negative number input - NumberEditingTextController.integer({ + factory NumberEditingTextController.integer({ String? locale, num? value, String? groupSeparator, - bool allowNegative = true, - }) : _locale = locale, - _groupSeparator = groupSeparator, - _allowNegative = allowNegative, - _currencyName = null, - _currencySymbol = null, - _decimalSeparator = null, - _minimalFractionDigits = null, - _maximumFractionDigits = null, - _formatType = _FormatType.integer, - _format = ParsedNumberFormat.integer( - locale: locale, - groupSeparator: groupSeparator, - allowNegative: allowNegative, - ) { - number = value; - } + bool allowNegative, + }) = IntegerEditingController; /// The locale used for number formatting. /// /// Setting this rebuilds the format and reformats the current value. + String? get locale; + set locale(String? value); + + /// The symbol used to group digits (e.g. `,` in `1,000`). + /// + /// Setting this rebuilds the format and reformats the current value. + String? get groupSeparator; + set groupSeparator(String? value); + + /// Whether negative number input is allowed. + /// + /// Setting this rebuilds the format and reformats the current value. + bool get allowNegative; + set allowNegative(bool value); + + /// The underlying numeric value extracted from the formatted text. + /// + /// Returns `null` when the text field is empty or contains no valid number. + num? get number; + + /// Sets the numeric value and updates the displayed text with formatting. + /// + /// Setting to `null` clears the text field. + set number(num? number); +} + +/// Shared implementation for all [NumberEditingTextController] subtypes. +mixin _NumberEditingMixin on TextEditingController + implements NumberEditingTextController { + ParsedNumberFormat get _format; + set _format(ParsedNumberFormat value); + + num? _number; + + String? _locale; + String? _groupSeparator; + bool _allowNegative = true; + + /// Subclasses must override to construct the appropriate format. + ParsedNumberFormat _buildFormat(); + + @override String? get locale => _locale; + + @override set locale(String? value) { if (_locale == value) return; _locale = value; _rebuildFormat(); } - /// The symbol used to group digits (e.g. `,` in `1,000`). - /// - /// Setting this rebuilds the format and reformats the current value. + @override String? get groupSeparator => _groupSeparator; + + @override set groupSeparator(String? value) { if (_groupSeparator == value) return; _groupSeparator = value; _rebuildFormat(); } - /// Whether negative number input is allowed. - /// - /// Setting this rebuilds the format and reformats the current value. + @override bool get allowNegative => _allowNegative; + + @override set allowNegative(bool value) { if (_allowNegative == value) return; _allowNegative = value; _rebuildFormat(); } - /// The underlying numeric value extracted from the formatted text. - /// - /// Returns `null` when the text field is empty or contains no valid number. + @override num? get number => _number; - /// Sets the numeric value and updates the displayed text with formatting. - /// - /// Setting to `null` clears the text field. + @override set number(num? number) { final effectiveNumber = number != null && !_allowNegative && number < 0 ? -number : number; @@ -197,40 +172,123 @@ class NumberEditingTextController extends TextEditingController { } void _rebuildFormat() { - _format = switch (_formatType) { - _FormatType.currency => ParsedNumberFormat.currency( - locale: _locale, - currencyName: _currencyName, - currencySymbol: _currencySymbol, - decimalSeparator: _decimalSeparator, - groupSeparator: _groupSeparator, - allowNegative: _allowNegative, - ), - _FormatType.decimal => ParsedNumberFormat.decimal( - locale: _locale, - minimalFractionDigits: _minimalFractionDigits, - maximumFractionDigits: _maximumFractionDigits, - decimalSeparator: _decimalSeparator, - groupSeparator: _groupSeparator, - allowNegative: _allowNegative, - ), - _FormatType.integer => ParsedNumberFormat.integer( - locale: _locale, - groupSeparator: _groupSeparator, - allowNegative: _allowNegative, - ), - }; - _reformat(); - } - - void _reformat() { + _format = _buildFormat(); if (_number != null) { - // Re-set via the number setter so the new format can alter the value - // (e.g. stripping the sign when allowNegative becomes false). - final currentNumber = _number!; - number = currentNumber; + number = _number; } } } -enum _FormatType { currency, decimal, integer } +/// A [NumberEditingTextController] that formats input as a currency amount. +/// +/// Currency-specific options like [currencyName], [currencySymbol], and +/// [decimalSeparator] can be changed at any time. +class CurrencyEditingController extends TextEditingController + with _NumberEditingMixin { + String? _currencyName; + String? _currencySymbol; + String? _decimalSeparator; + + @override + late ParsedNumberFormat _format; + + /// Creates a controller for formatting input as a currency amount. + CurrencyEditingController({ + String? locale, + String? currencyName, + String? currencySymbol, + num? value, + String? decimalSeparator, + String? groupSeparator, + bool allowNegative = true, + }) : _currencyName = currencyName, + _currencySymbol = currencySymbol, + _decimalSeparator = decimalSeparator { + _locale = locale; + _groupSeparator = groupSeparator; + _allowNegative = allowNegative; + _format = _buildFormat(); + number = value; + } + + @override + ParsedNumberFormat _buildFormat() => ParsedNumberFormat.currency( + locale: _locale, + currencyName: _currencyName, + currencySymbol: _currencySymbol, + decimalSeparator: _decimalSeparator, + groupSeparator: _groupSeparator, + allowNegative: _allowNegative, + ); +} + +/// A [NumberEditingTextController] that formats input as a decimal number. +/// +/// Decimal-specific options like [minimalFractionDigits], +/// [maximumFractionDigits], and [decimalSeparator] can be changed at any time. +class DecimalEditingController extends TextEditingController + with _NumberEditingMixin { + int? _minimalFractionDigits; + int? _maximumFractionDigits; + String? _decimalSeparator; + + @override + late ParsedNumberFormat _format; + + /// Creates a controller for formatting input as a decimal number. + DecimalEditingController({ + String? locale, + int? minimalFractionDigits, + int? maximumFractionDigits, + num? value, + String? decimalSeparator, + String? groupSeparator, + bool allowNegative = true, + }) : _minimalFractionDigits = minimalFractionDigits, + _maximumFractionDigits = maximumFractionDigits, + _decimalSeparator = decimalSeparator { + _locale = locale; + _groupSeparator = groupSeparator; + _allowNegative = allowNegative; + _format = _buildFormat(); + number = value; + } + + @override + ParsedNumberFormat _buildFormat() => ParsedNumberFormat.decimal( + locale: _locale, + minimalFractionDigits: _minimalFractionDigits, + maximumFractionDigits: _maximumFractionDigits, + decimalSeparator: _decimalSeparator, + groupSeparator: _groupSeparator, + allowNegative: _allowNegative, + ); +} + +/// A [NumberEditingTextController] that formats input as an integer. +class IntegerEditingController extends TextEditingController + with _NumberEditingMixin { + @override + late ParsedNumberFormat _format; + + /// Creates a controller for formatting input as an integer. + IntegerEditingController({ + String? locale, + num? value, + String? groupSeparator, + bool allowNegative = true, + }) { + _locale = locale; + _groupSeparator = groupSeparator; + _allowNegative = allowNegative; + _format = _buildFormat(); + number = value; + } + + @override + ParsedNumberFormat _buildFormat() => ParsedNumberFormat.integer( + locale: _locale, + groupSeparator: _groupSeparator, + allowNegative: _allowNegative, + ); +} From 19088cca0cb72e1c11317612dcd5f405ad41b0e4 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 11:13:54 +0300 Subject: [PATCH 4/7] Add mutable format options and tests Add mutable properties to controllers CurrencyEditingController: currencyName, currencySymbol, decimalSeparator DecimalEditingController: minimalFractionDigits, maximumFractionDigits, decimalSeparator Setters rebuild the format and reformat the current value when changed. Add tests verifying reformatting, no-op assignments (no notifications), getters, and factory subclass types --- lib/src/text_controller.dart | 84 ++++++++++++- test/signature_test.dart | 221 +++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 3 deletions(-) diff --git a/lib/src/text_controller.dart b/lib/src/text_controller.dart index 5ca24be..c1d22eb 100644 --- a/lib/src/text_controller.dart +++ b/lib/src/text_controller.dart @@ -122,7 +122,9 @@ mixin _NumberEditingMixin on TextEditingController @override set locale(String? value) { - if (_locale == value) return; + if (_locale == value) { + return; + } _locale = value; _rebuildFormat(); } @@ -132,7 +134,9 @@ mixin _NumberEditingMixin on TextEditingController @override set groupSeparator(String? value) { - if (_groupSeparator == value) return; + if (_groupSeparator == value) { + return; + } _groupSeparator = value; _rebuildFormat(); } @@ -142,7 +146,9 @@ mixin _NumberEditingMixin on TextEditingController @override set allowNegative(bool value) { - if (_allowNegative == value) return; + if (_allowNegative == value) { + return; + } _allowNegative = value; _rebuildFormat(); } @@ -211,6 +217,42 @@ class CurrencyEditingController extends TextEditingController number = value; } + /// The 3-letter ISO 4217 currency code (e.g. USD, EUR, TRY). + /// + /// Setting this rebuilds the format and reformats the current value. + String? get currencyName => _currencyName; + set currencyName(String? value) { + if (_currencyName == value) { + return; + } + _currencyName = value; + _rebuildFormat(); + } + + /// The currency symbol (e.g. $, €, ₺). + /// + /// Setting this rebuilds the format and reformats the current value. + String? get currencySymbol => _currencySymbol; + set currencySymbol(String? value) { + if (_currencySymbol == value) { + return; + } + _currencySymbol = value; + _rebuildFormat(); + } + + /// The symbol used to separate the decimal part. + /// + /// Setting this rebuilds the format and reformats the current value. + String? get decimalSeparator => _decimalSeparator; + set decimalSeparator(String? value) { + if (_decimalSeparator == value) { + return; + } + _decimalSeparator = value; + _rebuildFormat(); + } + @override ParsedNumberFormat _buildFormat() => ParsedNumberFormat.currency( locale: _locale, @@ -254,6 +296,42 @@ class DecimalEditingController extends TextEditingController number = value; } + /// The minimum number of fraction digits to display. + /// + /// Setting this rebuilds the format and reformats the current value. + int? get minimalFractionDigits => _minimalFractionDigits; + set minimalFractionDigits(int? value) { + if (_minimalFractionDigits == value) { + return; + } + _minimalFractionDigits = value; + _rebuildFormat(); + } + + /// The maximum number of fraction digits to display. + /// + /// Setting this rebuilds the format and reformats the current value. + int? get maximumFractionDigits => _maximumFractionDigits; + set maximumFractionDigits(int? value) { + if (_maximumFractionDigits == value) { + return; + } + _maximumFractionDigits = value; + _rebuildFormat(); + } + + /// The symbol used to separate the decimal part. + /// + /// Setting this rebuilds the format and reformats the current value. + String? get decimalSeparator => _decimalSeparator; + set decimalSeparator(String? value) { + if (_decimalSeparator == value) { + return; + } + _decimalSeparator = value; + _rebuildFormat(); + } + @override ParsedNumberFormat _buildFormat() => ParsedNumberFormat.decimal( locale: _locale, diff --git a/test/signature_test.dart b/test/signature_test.dart index 944bdcb..3043864 100644 --- a/test/signature_test.dart +++ b/test/signature_test.dart @@ -380,6 +380,227 @@ void main() { }); }); + group('mutable CurrencyEditingController options', () { + test('changing currencyName reformats', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + value: 100, + ); + expect(controller.text, '\$100'); + controller.currencyName = 'EUR'; + expect(controller.text, '€100'); + expect(controller.number, 100); + }); + + test('changing currencySymbol reformats', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + value: 50, + ); + expect(controller.text, '\$50'); + controller.currencySymbol = '£'; + expect(controller.text, '£50'); + expect(controller.number, 50); + }); + + test('changing decimalSeparator reformats', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + value: 10.5, + ); + expect(controller.text, '\$10.5'); + controller.decimalSeparator = ','; + expect(controller.text, '\$10,5'); + expect(controller.number, 10.5); + }); + + test('setting same currencyName does not notify', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + value: 10, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.currencyName = 'USD'; + expect(callCount, 0); + }); + + test('setting same currencySymbol does not notify', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + currencySymbol: '\$', + value: 10, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.currencySymbol = '\$'; + expect(callCount, 0); + }); + + test('setting same decimalSeparator does not notify', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + decimalSeparator: '.', + value: 10, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.decimalSeparator = '.'; + expect(callCount, 0); + }); + + test('getters return current values', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + currencySymbol: '\$', + decimalSeparator: '.', + ); + expect(controller.currencyName, 'USD'); + expect(controller.currencySymbol, '\$'); + expect(controller.decimalSeparator, '.'); + + controller.currencyName = 'EUR'; + controller.currencySymbol = '€'; + controller.decimalSeparator = ','; + expect(controller.currencyName, 'EUR'); + expect(controller.currencySymbol, '€'); + expect(controller.decimalSeparator, ','); + }); + + test('changing with null value keeps null', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + controller.currencyName = 'EUR'; + expect(controller.number, isNull); + expect(controller.text, ''); + }); + }); + + group('mutable DecimalEditingController options', () { + test('changing minimalFractionDigits reformats', () { + final controller = DecimalEditingController( + locale: 'en', + value: 3.5, + ); + expect(controller.text, '3.5'); + controller.minimalFractionDigits = 3; + expect(controller.text, '3.500'); + expect(controller.number, 3.5); + }); + + test('changing maximumFractionDigits reformats', () { + final controller = DecimalEditingController( + locale: 'en', + maximumFractionDigits: 4, + value: 3.1415, + ); + expect(controller.text, '3.1415'); + controller.maximumFractionDigits = 2; + expect(controller.text, '3.14'); + // number retains full precision — display is truncated + expect(controller.number, 3.1415); + }); + + test('changing decimalSeparator reformats', () { + final controller = DecimalEditingController( + locale: 'en', + value: 1.5, + ); + expect(controller.text, '1.5'); + controller.decimalSeparator = ','; + expect(controller.text, '1,5'); + expect(controller.number, 1.5); + }); + + test('setting same minimalFractionDigits does not notify', () { + final controller = DecimalEditingController( + locale: 'en', + minimalFractionDigits: 2, + value: 1.5, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.minimalFractionDigits = 2; + expect(callCount, 0); + }); + + test('setting same maximumFractionDigits does not notify', () { + final controller = DecimalEditingController( + locale: 'en', + maximumFractionDigits: 4, + value: 1.5, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.maximumFractionDigits = 4; + expect(callCount, 0); + }); + + test('setting same decimalSeparator does not notify', () { + final controller = DecimalEditingController( + locale: 'en', + decimalSeparator: '.', + value: 1.5, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.decimalSeparator = '.'; + expect(callCount, 0); + }); + + test('getters return current values', () { + final controller = DecimalEditingController( + locale: 'en', + minimalFractionDigits: 1, + maximumFractionDigits: 4, + decimalSeparator: '.', + ); + expect(controller.minimalFractionDigits, 1); + expect(controller.maximumFractionDigits, 4); + expect(controller.decimalSeparator, '.'); + + controller.minimalFractionDigits = 2; + controller.maximumFractionDigits = 6; + controller.decimalSeparator = ','; + expect(controller.minimalFractionDigits, 2); + expect(controller.maximumFractionDigits, 6); + expect(controller.decimalSeparator, ','); + }); + + test('changing with null value keeps null', () { + final controller = DecimalEditingController(locale: 'en'); + controller.minimalFractionDigits = 2; + expect(controller.number, isNull); + expect(controller.text, ''); + }); + }); + + group('subclass type checks', () { + test('factory .currency returns CurrencyEditingController', () { + final controller = NumberEditingTextController.currency(); + expect(controller, isA()); + }); + + test('factory .decimal returns DecimalEditingController', () { + final controller = NumberEditingTextController.decimal(); + expect(controller, isA()); + }); + + test('factory .integer returns IntegerEditingController', () { + final controller = NumberEditingTextController.integer(); + expect(controller, isA()); + }); + }); + group('constructor parameter defaults', () { test('currency defaults to allowNegative=true', () { final controller = NumberEditingTextController.currency( From 1f3fa9d7706b11038b98ccca8d249a0b7caba8be Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 11:19:47 +0300 Subject: [PATCH 5/7] Add TEST_CASES.md with extensive test cases --- test/TEST_CASES.md | 229 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 test/TEST_CASES.md diff --git a/test/TEST_CASES.md b/test/TEST_CASES.md new file mode 100644 index 0000000..9fcd36f --- /dev/null +++ b/test/TEST_CASES.md @@ -0,0 +1,229 @@ +# Test Cases + +Unicode legend: `NBSP` = `\u00A0` (non-breaking space), `NNBSP` = `\u202F` (narrow no-break space). + +## Currency Controller + +### Programmatic (`number =`) + +| Locale | Currency | Symbol | Decimal Sep | Group Sep | Allow Negative | Input | Expected Text | Expected Number | +|--------|----------|--------|-------------|-----------|----------------|-------|---------------|-----------------| +| en | EUR | | | | true | `null` | `` | `null` | +| en | EUR | | | | true | `10.5` | `€10.5` | `10.5` | +| en | EUR | | | | true | `42` then `null` | `` | `null` | +| en | USD | | | | true | `99.99` | `$99.99` | `99.99` | +| de | GBP | | | | true | `-5000.5` | `-5.000,5NBSp£` | `-5000.5` | +| es_ES | TRY | `₺` | | | false | `6612.54` | `6.612,54NBSP₺` | `6612.54` | +| en | USD | | `·` | | true | `10.5` | `$10·5` | `10.5` | +| en | USD | | | ` ` | true | `1000000` | `$1 000 000` | `1000000` | +| en | USD | | | | true | `0` | `$0` | `0` | +| en | USD | | | | true | `999999999` | `$999,999,999` | `999999999` | +| ja | JPY | | | | true | `1500` | `¥1,500` | `1500` | +| ko | KRW | | | | true | `50000` | `₩50,000` | `50000` | +| pt_BR | BRL | | | | true | `1234.56` | `R$NBSP1.234,56` | `1234.56` | +| tr | TRY | | | | true | `1234.56` | `TL1.234,56` | `1234.56` | +| da | DKK | | | | true | `1234.56` | `1.234,56NBSPkr` | `1234.56` | +| da | DKK | | | | true | `-500` | `-500NBSPkr` | `-500` | +| fr | EUR | | | | true | `1234.56` | `1NNBSP234,56NBSP€` | `1234.56` | +| ru | RUB | | | | true | `500` | `500NBSP₽` | `500` | +| en | USD | | `.` | `.` | true | `1234.56` | `$1.234.56` | `1234.56` | +| hi | INR | | | | true | `50000` | `₹50,000` | `50000` | + +### User Input (`value =`) + +| Locale | Currency | Options | Typed Text | Expected Text | Expected Number | +|--------|----------|---------|------------|---------------|-----------------| +| en | USD | | `1234` | `$1,234` | `1234` | +| en | USD | | `10.50` | `$10.50` | `10.5` | +| en | USD | | `$` (deleted digits) | `$` | `null` | +| ru | USD | | `NBSP$` (deleted digits) | `NBSP$` | `null` | +| en | USD | | `-100` | `$-100` | `-100` | +| en | USD | | `abc` | (with `$`) | `null` | +| en | USD | | `-25.75` | `$-25.75` | `-25.75` | +| ko | KRW | | `1000000` | `₩1,000,000` | `1000000` | +| pt_BR | BRL | | `5000` | `R$NBSP5.000` | `5000` | + +### Allow Negative + +| Locale | Currency | Allow Negative | Typed Text | Expected Text | Expected Number | +|--------|----------|----------------|------------|---------------|-----------------| +| ja | USD | false | `-` | `$` | - | +| ja | USD | false | `$-` | `$` | - | +| uk | UAH | false | `-` | `` | - | +| uk | UAH | false | `0` | `0NBSP₴` | - | + +## Integer Controller + +### Programmatic (`number =`) + +| Locale | Group Sep | Allow Negative | Input | Expected Text | Expected Number | +|--------|-----------|----------------|-------|---------------|-----------------| +| en | | true | `null` | `` | `null` | +| en | | true | `42` | `42` | `42` | +| de | | true | `-100` | `-100` | `-100` | +| en | | true | `1234567` | `1,234,567` | `1234567` | +| en | | true | `0` | `0` | `0` | +| en | | true | `500` | `500` | `500` | +| en | `.` | true | `1000000` | `1.000.000` | `1000000` | +| de | | true | `1234567` | `1.234.567` | `1234567` | +| en | | true | `999` | `999` | `999` | +| en | | true | `1000` | `1,000` | `1000` | +| en | | true | `1000000000` | `1,000,000,000` | `1000000000` | +| en | `` | true | `1234567` | `1234567` | `1234567` | +| en | | true | `-0` | `0` | `0` | + +### User Input (`value =`) + +| Locale | Options | Typed Text | Expected Text | Expected Number | +|--------|---------|------------|---------------|-----------------| +| en | | `1000000` | `1,000,000` | `1000000` | +| en | | `123.45` | (truncated) | `123` | +| en | | `5` | `5` | `5` | +| en | | `12345` | `12,345` | `12345` | +| en | | `1,23,456` | `123,456` | `123456` | +| en | | `` | `` | `null` | +| en | | `0` | `0` | `0` | +| en | | `007` | (leading zeros) | `7` | +| en | | `12abc34` | (stops at `a`) | `12` | +| en | | `1@2` | (stops at `@`) | `1` | +| en | | `-50` | `-50` | `-50` | +| en | allowNegative: false | `-5` | `5` | `5` | +| en | | `-` | `-` | `0` | +| en | | `-100000` | `-100,000` | `-100000` | +| en | | `-1000000` | `-1,000,000` | `-1000000` | +| en | | `-1234567890` | `-1,234,567,890` | `-1234567890` | + +## Decimal Controller + +### Programmatic (`number =`) + +| Locale | Min Frac | Max Frac | Decimal Sep | Group Sep | Input | Expected Text | Expected Number | +|--------|----------|----------|-------------|-----------|-------|---------------|-----------------| +| en | | | | | `null` | `` | `null` | +| de | | 6 | | | `-100.51241` | `-100,51241` | `-100.51241` | +| de | | 6 | | | `-1100` | `-1.100` | `-1100` | +| en | 2 | 4 | | | `10` | `10.00` | `10` | +| en | 1 | 4 | | | `5.10` | `5.1` | `5.1` | +| en | | 2 | | | `0` | `0` | `0` | +| en | | 2 | | | `1234567.89` | `1,234,567.89` | `1234567.89` | +| en | | 2 | | | `3.14` | `3.14` | `3.14` | +| en | | 2 | `,` | | `3.14` | `3,14` | `3.14` | +| en | | 2 | | ` ` | `1234567.89` | `1 234 567.89` | `1234567.89` | +| de | | 2 | | | `1234.56` | `1.234,56` | `1234.56` | +| en | 3 | 8 | | | `1.5` | `1.500` | `1.5` | +| en | 3 | 8 | | | `1.0` | `1.000` | `1.0` | +| en | 3 | 8 | | | `1.123456789` | `1.12345679` | `1.123456789` | +| en | | 10 | | | `0.0000001` | `0.0000001` | `0.0000001` | +| en | | 2 | | | `-0.0` | `0` | `-0.0` | +| en | | 2 | `,` | | `42.99` | `42,99` | `42.99` | +| en | | 2 | | ` ` | `1234567.89` | `1 234 567.89` | `1234567.89` | + +### User Input (`value =`) + +| Locale | Options | Typed Text | Expected Text | Expected Number | +|--------|---------|------------|---------------|-----------------| +| en | maxFrac: 3 | `42.123` | `42.123` | `42.123` | +| en | maxFrac: 2 | `1.23456` | `1.23` | `1.23` | +| en | maxFrac: 2 | `-5.14` | `-5.14` | `-5.14` | +| en | maxFrac: 4 | `-0.5` | `-0.5` | `-0.5` | +| en | maxFrac: 4 | `-100.99` | `-100.99` | `-100.99` | +| en | maxFrac: 4 | `-1.0001` | `-1.0001` | `-1.0001` | +| en | allowNeg: false, maxFrac: 2 | `-1.5` | `1.5` | `1.5` | +| en | minFrac: 0, maxFrac: 3 | `1.12345` | `1.123` | `1.123` | +| de | maxFrac: 2 | `1234,56` | `1.234,56` | `1234.56` | +| de | maxFrac: 2 | `-1234,56` | `-1.234,56` | `-1234.56` | +| de | | `1234567` | `1.234.567` | `1234567` | + +## Caret Position + +### Integer + +| Typed Text | Caret In | Expected Text | Caret Out | +|------------|----------|---------------|-----------| +| `5` | 1 | `5` | 1 | +| `1234` | 4 | `1,234` | 5 | +| `1,2345` | 6 | `12,345` | 6 | +| `12,3456` | 7 | `123,456` | 7 | +| `1,345` (deleted `2`) | 1 | `1,345` | 2 | +| `12,34` (deleted last) | 5 | `1,234` | 5 | + +### Currency (USD, en) + +| Typed Text | Caret In | Expected Text | Caret Out | +|------------|----------|---------------|-----------| +| `1` | 1 | `$1` | 2 | +| `$12` | 3 | `$12` | 3 | +| `$1234` | 5 | `$1,234` | 6 | +| `$1,2345` | 7 | `$12,345` | 7 | + +### Decimal (en, maxFrac: 2) + +| Typed Text | Caret In | Expected Text | Caret Out | +|------------|----------|---------------|-----------| +| `1.5` | 3 | `1.5` | 3 | +| `1.50` | 4 | `1.50` | 4 | +| `12345.67` | 8 | `12,345.67` | 9 | + +## Bug Regressions + +| Description | Type | Locale | Options | Input | Expected Text | Expected Number | +|-------------|------|--------|---------|-------|---------------|-----------------| +| Negative decimal parsed incorrectly | decimal | en | maxFrac: 2 | type `-5.14` | `-5.14` | `-5.14` | +| Group separator at start crashes | integer | en | | type `,5` | - | `null` or `0` | +| Group separator alone crashes | integer | en | | type `,` | - | `null` or `0` | +| NaN crashes | integer | en | | set `NaN` | (no crash) | - | +| Infinity crashes | integer | en | | set `Infinity` | (no crash) | - | +| Negative infinity crashes | decimal | en | maxFrac: 2 | set `-Infinity` | (no crash) | - | +| Negative 6+ digits wrong grouping | integer | en | | type `-100000` | `-100,000` | `-100000` | +| Minus before currency symbol duplicates symbol | currency | en | JPY | type `-¥123,456` | `¥123,456` | `123456` | +| Minus before currency symbol (allowNeg: false) | currency | en | JPY, allowNeg: false | type `-¥123,456` | `¥123,456` | `123456` | + +## Mutable Options + +### Locale Change + +| Type | Initial Locale | Initial Value | New Locale | Expected Text | Expected Number | +|------|----------------|---------------|------------|---------------|-----------------| +| integer | en | `1234567` | de | `1.234.567` | `1234567` | +| currency (EUR) | en | `1234.56` | de | `1.234,56NBSP€` | `1234.56` | +| decimal | en | `1234.5` | de | `1.234,5` | `1234.5` | + +### Group Separator Change + +| Type | Locale | Initial Value | New Separator | Expected Text | Expected Number | +|------|--------|---------------|---------------|---------------|-----------------| +| integer | en | `1234567` | ` ` | `1 234 567` | `1234567` | +| currency (USD) | en | `5000` | `.` | `$5.000` | `5000` | +| decimal | en | `12345.67` | ` ` | `12 345.67` | `12345.67` | + +### Allow Negative Change + +| Type | Locale | Initial Value | New allowNegative | Expected Text | Expected Number | +|------|--------|---------------|-------------------|---------------|-----------------| +| integer | en | `-500` | false | `500` | `500` | +| decimal | en | `-3.14` | false | `3.14` | `3.14` | +| currency (USD) | en | `-42` | false | `$42` | `42` | +| integer | en | `100` (allowNeg: false) | true | `100` | `100` | + +### Currency-Specific Options + +| Option | Initial | New Value | Initial Value | Expected Text | Expected Number | +|--------|---------|-----------|---------------|---------------|-----------------| +| currencyName | USD | EUR | `100` | `€100` | `100` | +| currencySymbol | (USD default) | `£` | `50` | `£50` | `50` | +| decimalSeparator | `.` | `,` | `10.5` | `$10,5` | `10.5` | + +### Decimal-Specific Options + +| Option | Initial | New Value | Initial Value | Expected Text | Expected Number | +|--------|---------|-----------|---------------|---------------|-----------------| +| minimalFractionDigits | (default) | 3 | `3.5` | `3.500` | `3.5` | +| maximumFractionDigits | 4 | 2 | `3.1415` | `3.14` | `3.1415` | +| decimalSeparator | `.` | `,` | `1.5` | `1,5` | `1.5` | + +### Combined Mutations + +| Steps | Initial Value | Expected After Each Step | +|-------|---------------|--------------------------| +| locale: en -> de, separator: -> ` ` | `1000000` | `1.000.000` -> `1 000 000` | +| separator: -> `.`, allowNeg: -> false | `-5000` | `-5.000` -> `5.000` | From ede4b52d7861a5bf5986da09987278a7fd91a864 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 11:26:49 +0300 Subject: [PATCH 6/7] Revamp README and update example app Expand documentation with quick start, configuration, runtime option changes, class hierarchy, and locale examples. Update example app to use CurrencyEditingController, DecimalEditingController, and IntegerEditingController; add currency/locale pickers, change UI layout and titles, and demonstrate mutable formatting options at runtime --- README.md | 188 +++++++++++++++-------- example/lib/main.dart | 44 +----- example/lib/variants/currency_input.dart | 63 +++++--- example/lib/variants/decimal_input.dart | 64 +++++--- example/lib/variants/integer_input.dart | 59 ++++--- 5 files changed, 250 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 6f10e18..74b3fce 100644 --- a/README.md +++ b/README.md @@ -3,124 +3,186 @@ [![Pub Version](https://img.shields.io/pub/v/number_editing_controller)](https://pub.dev/packages/number_editing_controller) [![License](https://img.shields.io/github/license/nerdy-pro/flutter_number_editing_controller)](https://github.com/nerdy-pro/flutter_number_editing_controller/blob/main/LICENSE) -A Flutter `TextEditingController` that automatically formats numbers, decimals, and currencies as the user types. Built with full locale support. +A Flutter `TextEditingController` that formats numbers, decimals, and currencies as the user types. Supports locale-aware grouping, decimal separators, currency symbols, and live option changes without recreating the controller. Developed by [nerdy.pro](https://nerdy.pro). -## Features +## What does it do? -- **As-you-type formatting** for integers, decimals, and currency amounts -- **Locale-aware** grouping and decimal separators (e.g. `1,234.56` in English, `1.234,56` in German) -- **Currency support** with automatic symbol placement based on locale -- **Extracts the numeric value** via `controller.number` -- **Negative number support** with optional `allowNegative` flag -- **Custom separators** for decimal and grouping characters +Drop a `NumberEditingTextController` into any `TextField`. The user sees formatted text (`$1,234.56`), and your code reads the raw numeric value via `controller.number`. -![number_editing_controller demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/screenshot.gif) +- Formats integers, decimals, and currencies as the user types +- Places grouping separators (`1,000,000`) and decimal separators (`3.14`) based on locale +- Positions currency symbols before or after the number depending on locale rules +- Extracts the underlying `num?` value at any time +- Supports negative numbers with an optional `allowNegative` flag +- Allows custom separators for grouping and decimal characters +- Lets you change locale, currency, separators, and precision at runtime -## Getting started +![number_editing_controller demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/screenshot.gif) -Install the package: +## Installation ```shell flutter pub add number_editing_controller ``` -## Usage +Requires Flutter 3.19+ and Dart 3.3+. -Create a controller and assign it to a `TextField`: +## Quick start ```dart -final controller = NumberEditingTextController.currency(currencyName: 'USD'); +import 'package:number_editing_controller/number_editing_controller.dart'; + +// Create a currency controller +final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', +); +// Use it in a TextField TextField( controller: controller, keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true), ) + +// Read the value +final amount = controller.number; // e.g. 1234.56 ``` -Read the numeric value at any time: +## Controller types -```dart -final value = controller.number; // e.g. 1234.56 -``` +### Integer -### Integer input +Formats whole numbers with grouping separators. ```dart -final controller = NumberEditingTextController.integer(); +final controller = NumberEditingTextController.integer(locale: 'en'); +// User types "1000000" -> displays "1,000,000" ``` -### Decimal input +### Decimal + +Formats numbers with a decimal part. Control minimum and maximum fraction digits. ```dart -final controller = NumberEditingTextController.decimal(); +final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 2, + maximumFractionDigits: 4, +); +// User types "3.5" -> displays "3.50" ``` -### Currency input +### Currency + +Formats monetary amounts with a currency symbol placed according to locale rules. ```dart -final controller = NumberEditingTextController.currency(currencyName: 'EUR'); +final controller = NumberEditingTextController.currency( + currencyName: 'EUR', + locale: 'de', +); +// controller.number = 1234.56 -> displays "1.234,56 €" ``` -### Configuration options +## Configuration + +### Shared parameters + +All controller types accept these parameters, both in the constructor and as mutable properties: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `locale` | `String?` | Current locale | Locale for formatting (e.g. `'en'`, `'de'`, `'ja'`). | +| `groupSeparator` | `String?` | From locale | Symbol used to group digits (e.g. `,` in `1,000`). | +| `allowNegative` | `bool` | `true` | Whether to allow negative numbers. | +| `value` | `num?` | `null` | Initial numeric value (constructor only). | -All constructors accept the following parameters: +### Currency parameters -| Parameter | Description | -|-----------|-------------| -| `locale` | Locale for number formatting (e.g. `'en'`, `'de'`, `'ja'`). Defaults to the current locale. | -| `allowNegative` | Whether to allow negative numbers. Defaults to `true`. | -| `groupSeparator` | Custom digit grouping symbol (e.g. `','`, `'.'`, `' '`). | -| `value` | Initial numeric value. | +Available on `CurrencyEditingController` and `NumberEditingTextController.currency()`: -The `currency()` constructor also accepts: +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `currencyName` | `String?` | From locale | ISO 4217 currency code (e.g. `'USD'`, `'EUR'`, `'JPY'`). | +| `currencySymbol` | `String?` | From currency code | Custom currency symbol (e.g. `'$'`, `'€'`, `'₺'`). | +| `decimalSeparator` | `String?` | From locale | Symbol used to separate the decimal part. | -| Parameter | Description | -|-----------|-------------| -| `currencyName` | ISO 4217 currency code (e.g. `'USD'`, `'EUR'`, `'JPY'`). | -| `currencySymbol` | Custom currency symbol (e.g. `'$'`, `'€'`, `'₺'`). | -| `decimalSeparator` | Custom decimal separator symbol. | +### Decimal parameters -The `decimal()` constructor also accepts: +Available on `DecimalEditingController` and `NumberEditingTextController.decimal()`: -| Parameter | Description | -|-----------|-------------| -| `minimalFractionDigits` | Minimum number of decimal digits to display. | -| `maximumFractionDigits` | Maximum number of decimal digits allowed. | -| `decimalSeparator` | Custom decimal separator symbol. | +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `minimalFractionDigits` | `int?` | From locale pattern | Minimum number of decimal digits to display. | +| `maximumFractionDigits` | `int?` | From locale pattern | Maximum number of decimal digits allowed. | +| `decimalSeparator` | `String?` | From locale | Symbol used to separate the decimal part. | -### Examples +## Changing options at runtime + +All formatting options are mutable. Change them at any time and the displayed text updates automatically. This is useful for currency pickers, locale switchers, or toggling negative input. ```dart -// US Dollar with locale -final usd = NumberEditingTextController.currency( +final controller = CurrencyEditingController( currencyName: 'USD', locale: 'en', ); -// Euro in German locale -final eur = NumberEditingTextController.currency( - currencyName: 'EUR', - locale: 'de', -); +// User switches currency +controller.currencyName = 'EUR'; // "$1,234.56" -> "€1,234.56" + +// User switches locale +controller.locale = 'de'; // "€1,234.56" -> "1.234,56 €" + +// Disable negative input +controller.allowNegative = false; // "-500" -> "500" -// Positive-only integer -final positive = NumberEditingTextController.integer( - allowNegative: false, +// Change group separator +controller.groupSeparator = ' '; // "1.234" -> "1 234" +``` + +### Using subclasses directly + +The factory constructors (`NumberEditingTextController.currency()`, `.decimal()`, `.integer()`) return the appropriate subclass. You can also instantiate them directly to access type-specific setters: + +```dart +final controller = CurrencyEditingController( + currencyName: 'USD', + locale: 'en', ); +controller.currencyName = 'GBP'; // Only available on CurrencyEditingController -// Decimal with precision control -final precise = NumberEditingTextController.decimal( - minimalFractionDigits: 2, - maximumFractionDigits: 4, +final decimal = DecimalEditingController( locale: 'en', + maximumFractionDigits: 4, ); +decimal.maximumFractionDigits = 2; // Only available on DecimalEditingController ``` -A working example app is available in the [example](https://github.com/nerdy-pro/flutter_number_editing_controller/tree/main/example) directory. +## Class hierarchy -### Disposing the controller +| Class | Description | +|-------|-------------| +| `NumberEditingTextController` | Interface. Use the factory constructors or program against this type. | +| `CurrencyEditingController` | Formats currency amounts. Mutable: `currencyName`, `currencySymbol`, `decimalSeparator`. | +| `DecimalEditingController` | Formats decimal numbers. Mutable: `minimalFractionDigits`, `maximumFractionDigits`, `decimalSeparator`. | +| `IntegerEditingController` | Formats integers. No type-specific options beyond the shared ones. | + +## Locale examples + +| Locale | Type | Value | Formatted | +|--------|------|-------|-----------| +| `en` | Currency (USD) | `1234.56` | `$1,234.56` | +| `de` | Currency (EUR) | `1234.56` | `1.234,56 €` | +| `fr` | Currency (EUR) | `1234.56` | `1 234,56 €` | +| `ja` | Currency (JPY) | `1500` | `¥1,500` | +| `ru` | Currency (RUB) | `500` | `500 ₽` | +| `en` | Integer | `1234567` | `1,234,567` | +| `de` | Integer | `1234567` | `1.234.567` | +| `en` | Decimal (max 2) | `1234567.89` | `1,234,567.89` | + +## Disposing the controller Like any `TextEditingController`, dispose of it when it is no longer needed: @@ -131,3 +193,7 @@ void dispose() { super.dispose(); } ``` + +## Example app + +A working example app with currency picker, locale switcher, and negative toggle is available in the [example](https://github.com/nerdy-pro/flutter_number_editing_controller/tree/main/example) directory. diff --git a/example/lib/main.dart b/example/lib/main.dart index 2a68cf6..813db23 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,54 +10,22 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Number Editing Controller', theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a blue toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + home: const HomePage(), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +class HomePage extends StatelessWidget { + const HomePage({super.key}); - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { @override Widget build(BuildContext context) { return DefaultTabController( @@ -65,12 +33,12 @@ class _MyHomePageState extends State { child: Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), + title: const Text('Number Editing Controller'), bottom: const TabBar( tabs: [ Tab(text: 'Currency'), Tab(text: 'Integer'), - Tab(text: 'Double'), + Tab(text: 'Decimal'), ], ), ), diff --git a/example/lib/variants/currency_input.dart b/example/lib/variants/currency_input.dart index 5e7a37b..d2fd966 100644 --- a/example/lib/variants/currency_input.dart +++ b/example/lib/variants/currency_input.dart @@ -9,11 +9,15 @@ class CurrencyInput extends StatefulWidget { } class _CurrencyInputState extends State { - final _controller = NumberEditingTextController.currency( - currencyName: 'JPY', - allowNegative: true, + final _controller = CurrencyEditingController( + currencyName: 'USD', + locale: 'en', ); + String _selectedCurrency = 'USD'; + + static const _currencies = ['USD', 'EUR', 'GBP', 'JPY', 'TRY']; + @override void dispose() { _controller.dispose(); @@ -23,24 +27,37 @@ class _CurrencyInputState extends State { @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have entered:', - ), - ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, child) { - return Text( - '${_controller.number ?? 0}', - style: Theme.of(context).textTheme.headlineMedium, - ); - }, - ), - Padding( - padding: const EdgeInsets.all(16), - child: TextField( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DropdownButton( + value: _selectedCurrency, + items: _currencies + .map((c) => DropdownMenuItem(value: c, child: Text(c))) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedCurrency = value; + _controller.currencyName = value; + }); + }, + ), + const SizedBox(height: 16), + const Text('You have entered:'), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) { + return Text( + '${_controller.number ?? 0}', + style: Theme.of(context).textTheme.headlineMedium, + ); + }, + ), + const SizedBox(height: 16), + TextField( textAlign: TextAlign.center, keyboardType: const TextInputType.numberWithOptions( decimal: true, @@ -48,8 +65,8 @@ class _CurrencyInputState extends State { ), controller: _controller, ), - ), - ], + ], + ), ), ); } diff --git a/example/lib/variants/decimal_input.dart b/example/lib/variants/decimal_input.dart index b752090..99e50ee 100644 --- a/example/lib/variants/decimal_input.dart +++ b/example/lib/variants/decimal_input.dart @@ -9,7 +9,14 @@ class DecimalInput extends StatefulWidget { } class _DecimalInputState extends State { - final _controller = NumberEditingTextController.decimal(allowNegative: false); + final _controller = DecimalEditingController( + locale: 'en', + maximumFractionDigits: 4, + ); + + String _selectedLocale = 'en'; + + static const _locales = ['en', 'de', 'fr', 'ja', 'ru']; @override void dispose() { @@ -20,33 +27,46 @@ class _DecimalInputState extends State { @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have entered:', - ), - ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, child) { - return Text( - '${_controller.number ?? 0}', - style: Theme.of(context).textTheme.headlineMedium, - ); - }, - ), - Padding( - padding: const EdgeInsets.all(16), - child: TextField( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DropdownButton( + value: _selectedLocale, + items: _locales + .map((l) => DropdownMenuItem(value: l, child: Text(l))) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedLocale = value; + _controller.locale = value; + }); + }, + ), + const SizedBox(height: 16), + const Text('You have entered:'), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) { + return Text( + '${_controller.number ?? 0}', + style: Theme.of(context).textTheme.headlineMedium, + ); + }, + ), + const SizedBox(height: 16), + TextField( textAlign: TextAlign.center, keyboardType: const TextInputType.numberWithOptions( decimal: true, - signed: false, + signed: true, ), controller: _controller, ), - ), - ], + ], + ), ), ); } diff --git a/example/lib/variants/integer_input.dart b/example/lib/variants/integer_input.dart index c93ba7a..4a92e95 100644 --- a/example/lib/variants/integer_input.dart +++ b/example/lib/variants/integer_input.dart @@ -9,7 +9,9 @@ class IntegerInput extends StatefulWidget { } class _IntegerInputState extends State { - final _controller = NumberEditingTextController.integer(allowNegative: false); + final _controller = IntegerEditingController(locale: 'en'); + + bool _allowNegative = true; @override void dispose() { @@ -20,33 +22,42 @@ class _IntegerInputState extends State { @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have entered:', - ), - ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, child) { - return Text( - '${_controller.number ?? 0}', - style: Theme.of(context).textTheme.headlineMedium, - ); - }, - ), - Padding( - padding: const EdgeInsets.all(16), - child: TextField( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SwitchListTile( + title: const Text('Allow negative'), + value: _allowNegative, + onChanged: (value) { + setState(() { + _allowNegative = value; + _controller.allowNegative = value; + }); + }, + ), + const SizedBox(height: 16), + const Text('You have entered:'), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) { + return Text( + '${_controller.number ?? 0}', + style: Theme.of(context).textTheme.headlineMedium, + ); + }, + ), + const SizedBox(height: 16), + TextField( textAlign: TextAlign.center, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, + keyboardType: TextInputType.numberWithOptions( + signed: _allowNegative, ), controller: _controller, ), - ), - ], + ], + ), ), ); } From 5d97d582b33c7d3527bbebb9461fc35668aa5f08 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 11:47:10 +0300 Subject: [PATCH 7/7] Add known limitations and fix parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document known limitations in README (precision, grouping, fraction digits, RTL and runtime option changes) Clamp decimal fraction digits to 0–20 in ParsedNumberFormat Simplify RealPart parsing, handle grouping correctly, stop inserting an automatic zero, and return null width for StaticPart Minor parentheses cleanup in text controller --- README.md | 29 +++++++++++++++++++++++++++++ lib/src/parsed_number_format.dart | 2 +- lib/src/parts.dart | 16 ++++++---------- lib/src/text_controller.dart | 2 +- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 74b3fce..54a48f0 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,35 @@ decimal.maximumFractionDigits = 2; // Only available on DecimalEditingController | `de` | Integer | `1234567` | `1.234.567` | | `en` | Decimal (max 2) | `1234567.89` | `1,234,567.89` | +## Known limitations + +### Number precision + +The controller uses Dart's `num` type (`int` and `double`). + +- **Flutter web**: JavaScript represents all numbers as 64-bit floats. Integers above `2^53 - 1` (9,007,199,254,740,991) lose precision silently. This affects both typed input and programmatic values. +- **Floating-point rounding**: `double` provides ~15-17 significant digits. Values like `12345678901234.56` will lose the fractional part. Standard IEEE 754 rounding artifacts apply (e.g. `1.005.toStringAsFixed(2)` produces `"1.00"`). +- **Native platforms**: 64-bit integers support up to `2^63 - 1` (~9.2 quintillion). + +### Grouping + +The library uses a uniform group size derived from the locale's ICU format pattern (typically groups of 3). **Variable-width grouping systems are not supported.** This affects the Indian/South Asian numbering system (lakh/crore), where groups alternate between 2 and 3 digits. For example, `12,34,56,789` would render as `123,456,789` instead. + +### Decimal fraction digits + +- Maximum of 20 fraction digits (Dart's `toStringAsFixed` limit). Values beyond 20 are clamped. +- `minimalFractionDigits` must not exceed `maximumFractionDigits`. No runtime validation is performed; invalid combinations will cause errors. + +### Locale and formatting + +- **No RTL support**: Arabic, Hebrew, and other right-to-left locales are not handled. Characters are inserted left-to-right. +- **Single-character decimal separator**: Multi-character decimal separators are not supported. +- **No input length limit**: Extremely long inputs may degrade formatting performance. + +### Runtime option changes + +Changing formatting options (locale, currency, separators) at runtime reformats from the stored numeric value. Any partial typing state (e.g. a trailing decimal separator the user just entered) is discarded. + ## Disposing the controller Like any `TextEditingController`, dispose of it when it is no longer needed: diff --git a/lib/src/parsed_number_format.dart b/lib/src/parsed_number_format.dart index 13a95a1..210e48c 100644 --- a/lib/src/parsed_number_format.dart +++ b/lib/src/parsed_number_format.dart @@ -226,7 +226,7 @@ class ParsedNumberFormat { } if (part is DecimalPart) { final min = part.minLength; - final max = part.maxLength; + final max = part.maxLength.clamp(0, 20); final stringValue = value.toStringAsFixed(max).split('.').last; final hasSignificantDigits = diff --git a/lib/src/parts.dart b/lib/src/parts.dart index 8ea4b14..3e94308 100644 --- a/lib/src/parts.dart +++ b/lib/src/parts.dart @@ -69,7 +69,7 @@ class StaticPart extends NumberFormatPart { i++; } - return PartFormatResult(v, i, 0); + return PartFormatResult(v, i, null); } @override @@ -145,11 +145,11 @@ class RealPart extends NumberFormatPart { if (i == 0) { return PartFormatResult(v, 0, null); } - if (i != 0) { - final numberText = v.text.substring(position, position + i); - number = numberText == '-' ? 0 : num.parse(numberText); - } - if (i != 0 && g is WithGrouping) { + + final numberText = v.text.substring(position, position + i); + number = numberText == '-' ? 0 : num.parse(numberText); + + if (g is WithGrouping) { final hasMinusSign = v.text[position] == '-' && allowNegative; final digitLength = hasMinusSign ? i - 1 : i; @@ -164,10 +164,6 @@ class RealPart extends NumberFormatPart { } } } - if (i == 0) { - v = v.replaced(TextRange.collapsed(position + i), '0'); - i++; - } return PartFormatResult(v, i, number); } diff --git a/lib/src/text_controller.dart b/lib/src/text_controller.dart index c1d22eb..6956237 100644 --- a/lib/src/text_controller.dart +++ b/lib/src/text_controller.dart @@ -159,7 +159,7 @@ mixin _NumberEditingMixin on TextEditingController @override set number(num? number) { final effectiveNumber = - number != null && !_allowNegative && number < 0 ? -number : number; + (number != null && !_allowNegative && number < 0) ? -number : number; _number = effectiveNumber; final text = effectiveNumber == null ? '' : _format.formatString(effectiveNumber);