diff --git a/README.md b/README.md index 6f10e18..54a48f0 100644 --- a/README.md +++ b/README.md @@ -3,124 +3,215 @@ [![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 + +| 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` | + +## Known limitations -### Disposing the controller +### 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: @@ -131,3 +222,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, ), - ), - ], + ], + ), ), ); } 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/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/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 e74073a..6956237 100644 --- a/lib/src/text_controller.dart +++ b/lib/src/text_controller.dart @@ -4,18 +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. -class NumberEditingTextController extends TextEditingController { - final ParsedNumberFormat _format; - - num? _number; - +/// +/// Formatting options such as [locale], [groupSeparator], and [allowNegative] +/// can be changed at any time. Changing an option automatically reformats the +/// current value. +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()] @@ -25,24 +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, - }) : _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. /// @@ -53,24 +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, - }) : _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. /// @@ -78,30 +66,103 @@ 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, - }) : _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 => _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(); + } + + @override + String? get groupSeparator => _groupSeparator; + + @override + set groupSeparator(String? value) { + if (_groupSeparator == value) { + return; + } + _groupSeparator = value; + _rebuildFormat(); + } + + @override + bool get allowNegative => _allowNegative; + + @override + set allowNegative(bool value) { + if (_allowNegative == value) { + return; + } + _allowNegative = value; + _rebuildFormat(); + } + + @override + num? get number => _number; + + @override 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 +176,197 @@ class NumberEditingTextController extends TextEditingController { _number = result.number; super.value = result.value; } + + void _rebuildFormat() { + _format = _buildFormat(); + if (_number != null) { + number = _number; + } + } +} + +/// 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; + } + + /// 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, + 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; + } + + /// 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, + 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, + ); } 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` | diff --git a/test/signature_test.dart b/test/signature_test.dart new file mode 100644 index 0000000..3043864 --- /dev/null +++ b/test/signature_test.dart @@ -0,0 +1,636 @@ +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('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('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( + 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); + }); + }); + }); +}