diff --git a/README.md b/README.md index 54a48f0..d947d4d 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ Drop a `NumberEditingTextController` into any `TextField`. The user sees formatt - Allows custom separators for grouping and decimal characters - Lets you change locale, currency, separators, and precision at runtime -![number_editing_controller demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/screenshot.gif) - ## Installation ```shell @@ -56,6 +54,8 @@ final amount = controller.number; // e.g. 1234.56 Formats whole numbers with grouping separators. +![Integer formatting demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/integer.gif) + ```dart final controller = NumberEditingTextController.integer(locale: 'en'); // User types "1000000" -> displays "1,000,000" @@ -65,6 +65,8 @@ final controller = NumberEditingTextController.integer(locale: 'en'); Formats numbers with a decimal part. Control minimum and maximum fraction digits. +![Decimal formatting demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/decimal.gif) + ```dart final controller = NumberEditingTextController.decimal( locale: 'en', @@ -78,6 +80,8 @@ final controller = NumberEditingTextController.decimal( Formats monetary amounts with a currency symbol placed according to locale rules. +![Currency formatting demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/currency.gif) + ```dart final controller = NumberEditingTextController.currency( currencyName: 'EUR', @@ -108,6 +112,14 @@ Available on `CurrencyEditingController` and `NumberEditingTextController.curren | `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. | +| `showCurrencySymbol` | `bool` | `true` | Whether to include the currency symbol in the formatted text. Set to `false` when displaying the symbol outside the text field. | + +`CurrencyEditingController` also exposes these read-only properties: + +| Property | Type | Description | +|----------|------|-------------| +| `resolvedCurrencySymbol` | `String` | The actual symbol used for formatting, resolved from `currencySymbol` or `currencyName` + `locale`. | +| `currencySymbolPosition` | `CurrencySymbolPosition` | Whether the symbol is a `.prefix` or `.suffix` in the current locale. | ### Decimal parameters @@ -160,12 +172,41 @@ final decimal = DecimalEditingController( decimal.maximumFractionDigits = 2; // Only available on DecimalEditingController ``` +### Displaying the currency symbol outside the text field + +Use `showCurrencySymbol: false` to hide the symbol from the formatted text, then display it as a prefix or suffix decoration on the `TextField`. The `resolvedCurrencySymbol` and `currencySymbolPosition` properties tell you what to display and where. + +![Currency prefix/suffix demo](https://raw.githubusercontent.com/nerdy-pro/flutter_number_editing_controller/main/img/currency-prefix.gif) + +```dart +final controller = CurrencyEditingController( + currencyName: 'USD', + locale: 'en', + showCurrencySymbol: false, +); + +TextField( + controller: controller, + decoration: InputDecoration( + prefixText: controller.currencySymbolPosition == CurrencySymbolPosition.prefix + ? controller.resolvedCurrencySymbol + : null, + suffixText: controller.currencySymbolPosition == CurrencySymbolPosition.suffix + ? controller.resolvedCurrencySymbol + : null, + ), +) +// User types "1234" -> field shows "$" as prefix + "1,234" as text +``` + +This is useful when you want the symbol to remain fixed and not shift as the user types, or when you need custom styling for the symbol. + ## Class hierarchy | Class | Description | |-------|-------------| | `NumberEditingTextController` | Interface. Use the factory constructors or program against this type. | -| `CurrencyEditingController` | Formats currency amounts. Mutable: `currencyName`, `currencySymbol`, `decimalSeparator`. | +| `CurrencyEditingController` | Formats currency amounts. Mutable: `currencyName`, `currencySymbol`, `decimalSeparator`, `showCurrencySymbol`. Read-only: `resolvedCurrencySymbol`, `currencySymbolPosition`. | | `DecimalEditingController` | Formats decimal numbers. Mutable: `minimalFractionDigits`, `maximumFractionDigits`, `decimalSeparator`. | | `IntegerEditingController` | Formats integers. No type-specific options beyond the shared ones. | @@ -225,4 +266,4 @@ void 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. +A working example app is available in the [example](https://github.com/nerdy-pro/flutter_number_editing_controller/tree/main/example) directory. It demonstrates currency picker, locale switcher, negative toggle, and external currency symbol placement with prefix/suffix decoration. diff --git a/example/lib/main.dart b/example/lib/main.dart index 813db23..5eadb9e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:number_editing_controller_example/variants/currency_input.dart'; import 'package:number_editing_controller_example/variants/decimal_input.dart'; +import 'package:number_editing_controller_example/variants/external_symbol_input.dart'; import 'package:number_editing_controller_example/variants/integer_input.dart'; void main() { @@ -29,7 +30,7 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 3, + length: 4, child: Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, @@ -39,6 +40,7 @@ class HomePage extends StatelessWidget { Tab(text: 'Currency'), Tab(text: 'Integer'), Tab(text: 'Decimal'), + Tab(text: 'Prefix/Suffix'), ], ), ), @@ -47,6 +49,7 @@ class HomePage extends StatelessWidget { CurrencyInput(), IntegerInput(), DecimalInput(), + ExternalSymbolInput(), ], ), ), diff --git a/example/lib/variants/external_symbol_input.dart b/example/lib/variants/external_symbol_input.dart new file mode 100644 index 0000000..f27e411 --- /dev/null +++ b/example/lib/variants/external_symbol_input.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:number_editing_controller/number_editing_controller.dart'; + +class ExternalSymbolInput extends StatefulWidget { + const ExternalSymbolInput({super.key}); + + @override + State createState() => _ExternalSymbolInputState(); +} + +class _ExternalSymbolInputState extends State { + final _controller = CurrencyEditingController( + locale: 'en', + showCurrencySymbol: false, + ); + + String _selectedLocale = 'en'; + + static const _locales = ['en', 'de', 'fr', 'ja', 'ru']; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isPrefix = _controller.currencySymbolPosition == + CurrencySymbolPosition.prefix; + + return Center( + 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: 8), + Text( + 'Symbol: ${_controller.resolvedCurrencySymbol}' + ' Position: ${_controller.currencySymbolPosition.name}', + style: Theme.of(context).textTheme.bodySmall, + ), + 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, + ), + controller: _controller, + decoration: InputDecoration( + prefixText: isPrefix + ? _controller.resolvedCurrencySymbol + : null, + suffixText: isPrefix + ? null + : _controller.resolvedCurrencySymbol, + border: const OutlineInputBorder(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/img/currency-prefix.gif b/img/currency-prefix.gif new file mode 100644 index 0000000..fd17e8d Binary files /dev/null and b/img/currency-prefix.gif differ diff --git a/img/currency.gif b/img/currency.gif new file mode 100644 index 0000000..e120728 Binary files /dev/null and b/img/currency.gif differ diff --git a/img/decimal.gif b/img/decimal.gif new file mode 100644 index 0000000..4d36b39 Binary files /dev/null and b/img/decimal.gif differ diff --git a/img/integer.gif b/img/integer.gif new file mode 100644 index 0000000..ca0ebbe Binary files /dev/null and b/img/integer.gif differ diff --git a/img/screenshot.gif b/img/screenshot.gif deleted file mode 100644 index 06f974a..0000000 Binary files a/img/screenshot.gif and /dev/null differ diff --git a/lib/number_editing_controller.dart b/lib/number_editing_controller.dart index 48af3d4..12f16a1 100644 --- a/lib/number_editing_controller.dart +++ b/lib/number_editing_controller.dart @@ -2,6 +2,7 @@ /// as you type with locale support. library; +export 'src/parsed_number_format.dart' show CurrencySymbolPosition; export 'src/text_controller.dart' show NumberEditingTextController, diff --git a/lib/src/parsed_number_format.dart b/lib/src/parsed_number_format.dart index 210e48c..b22e563 100644 --- a/lib/src/parsed_number_format.dart +++ b/lib/src/parsed_number_format.dart @@ -6,6 +6,15 @@ import 'package:number_editing_controller/src/grouping.dart'; import 'package:number_editing_controller/src/mask_parser_iterator.dart'; import 'package:number_editing_controller/src/parts.dart'; +/// Whether the currency symbol is placed before or after the number. +enum CurrencySymbolPosition { + /// The symbol appears before the number (e.g. `$100`). + prefix, + + /// The symbol appears after the number (e.g. `100 €`). + suffix, +} + /// The result of formatting a [TextEditingValue] through [ParsedNumberFormat]. class FormatResult { /// The formatted text editing value. @@ -47,6 +56,7 @@ class ParsedNumberFormat { String? decimalSeparator, String? groupSeparator, bool allowNegative = true, + bool showCurrencySymbol = true, }) { final currentLocale = _verifiedLocale(locale, NumberFormat.localeExists)!; final symbols = numberFormatSymbols[currentLocale] as NumberSymbols; @@ -62,6 +72,7 @@ class ParsedNumberFormat { decimalSeparator: decimalSeparator, groupSeparator: groupSeparator, allowNegative: allowNegative, + showCurrencySymbol: showCurrencySymbol, ); } @@ -121,6 +132,7 @@ class ParsedNumberFormat { String? decimalSeparator, String? groupSeparator, required bool allowNegative, + bool showCurrencySymbol = true, }) { final currencyCode = currencyName ?? symbols.DEF_CURRENCY_CODE; final format = NumberFormat(mask); @@ -129,7 +141,7 @@ class ParsedNumberFormat { final min = minimalFractionDigits ?? format.minimumFractionDigits; final max = maximumFractionDigits ?? format.maximumFractionDigits; - final parts = mask.getNumberFormatParts( + var parts = mask.getNumberFormatParts( minDecimalPart: min, maxDecimalPart: max, currencySign: resolvedCurrencySymbol, @@ -138,11 +150,49 @@ class ParsedNumberFormat { allowNegative: allowNegative, ); + if (!showCurrencySymbol) { + parts = parts.where((p) => p is! StaticPart).toList(); + } + return ParsedNumberFormat._(parts, allowNegative); } ParsedNumberFormat._(this.parts, this._allowNegative); + /// Resolves the currency symbol for the given parameters. + static String resolvedSymbol({ + String? locale, + String? currencyName, + String? currencySymbol, + }) { + if (currencySymbol != null) { + return currencySymbol; + } + final currentLocale = _verifiedLocale(locale, NumberFormat.localeExists)!; + final symbols = numberFormatSymbols[currentLocale] as NumberSymbols; + final currencyCode = currencyName ?? symbols.DEF_CURRENCY_CODE; + final format = NumberFormat(symbols.CURRENCY_PATTERN); + return format.simpleCurrencySymbol(currencyCode); + } + + /// Determines whether the currency symbol is a prefix or suffix + /// for the given locale. + static CurrencySymbolPosition symbolPosition({String? locale}) { + final currentLocale = _verifiedLocale(locale, NumberFormat.localeExists)!; + final symbols = numberFormatSymbols[currentLocale] as NumberSymbols; + final pattern = symbols.CURRENCY_PATTERN; + // In ICU patterns, \u00A4 is the currency placeholder. + // If it appears before the first digit placeholder, it's a prefix. + final currencyIndex = pattern.indexOf('\u00A4'); + final digitIndex = pattern.indexOf(RegExp('[0#]')); + if (currencyIndex < 0) { + return CurrencySymbolPosition.prefix; + } + return currencyIndex < digitIndex + ? CurrencySymbolPosition.prefix + : CurrencySymbolPosition.suffix; + } + FormatResult formatValue(TextEditingValue textEditingValue) { var result = textEditingValue; var charPosition = 0; diff --git a/lib/src/text_controller.dart b/lib/src/text_controller.dart index 6956237..7eae4e3 100644 --- a/lib/src/text_controller.dart +++ b/lib/src/text_controller.dart @@ -194,6 +194,7 @@ class CurrencyEditingController extends TextEditingController String? _currencyName; String? _currencySymbol; String? _decimalSeparator; + bool _showCurrencySymbol; @override late ParsedNumberFormat _format; @@ -207,9 +208,11 @@ class CurrencyEditingController extends TextEditingController String? decimalSeparator, String? groupSeparator, bool allowNegative = true, + bool showCurrencySymbol = true, }) : _currencyName = currencyName, _currencySymbol = currencySymbol, - _decimalSeparator = decimalSeparator { + _decimalSeparator = decimalSeparator, + _showCurrencySymbol = showCurrencySymbol { _locale = locale; _groupSeparator = groupSeparator; _allowNegative = allowNegative; @@ -253,6 +256,37 @@ class CurrencyEditingController extends TextEditingController _rebuildFormat(); } + /// Whether the currency symbol is shown in the formatted text. + /// + /// When `false`, the currency symbol and its separator are hidden. + /// This is useful when the symbol is displayed as a prefix or suffix + /// widget outside the text field. + /// + /// Defaults to `true`. + bool get showCurrencySymbol => _showCurrencySymbol; + set showCurrencySymbol(bool value) { + if (_showCurrencySymbol == value) { + return; + } + _showCurrencySymbol = value; + _rebuildFormat(); + } + + /// The resolved currency symbol used for formatting. + /// + /// Returns the custom [currencySymbol] if set, otherwise the symbol + /// derived from [currencyName] and [locale]. + String get resolvedCurrencySymbol => ParsedNumberFormat.resolvedSymbol( + locale: _locale, + currencyName: _currencyName, + currencySymbol: _currencySymbol, + ); + + /// Whether the currency symbol is placed before or after the number + /// in the current locale. + CurrencySymbolPosition get currencySymbolPosition => + ParsedNumberFormat.symbolPosition(locale: _locale); + @override ParsedNumberFormat _buildFormat() => ParsedNumberFormat.currency( locale: _locale, @@ -261,6 +295,7 @@ class CurrencyEditingController extends TextEditingController decimalSeparator: _decimalSeparator, groupSeparator: _groupSeparator, allowNegative: _allowNegative, + showCurrencySymbol: _showCurrencySymbol, ); } diff --git a/test/signature_test.dart b/test/signature_test.dart index 3043864..81ebff1 100644 --- a/test/signature_test.dart +++ b/test/signature_test.dart @@ -485,6 +485,230 @@ void main() { }); }); + group('CurrencyEditingController showCurrencySymbol', () { + test('hides symbol for leading symbol locale', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + showCurrencySymbol: false, + value: 1234.56, + ); + expect(controller.text, '1,234.56'); + expect(controller.number, 1234.56); + }); + + test('hides symbol and separator for trailing symbol locale', () { + final controller = CurrencyEditingController( + locale: 'de', + currencyName: 'EUR', + showCurrencySymbol: false, + value: 1234.56, + ); + expect(controller.text, '1.234,56'); + expect(controller.number, 1234.56); + }); + + test('toggling showCurrencySymbol reformats', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + value: 100, + ); + expect(controller.text, '\$100'); + controller.showCurrencySymbol = false; + expect(controller.text, '100'); + expect(controller.number, 100); + controller.showCurrencySymbol = true; + expect(controller.text, '\$100'); + expect(controller.number, 100); + }); + + test('setting same value does not notify', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + value: 10, + ); + var callCount = 0; + controller.addListener(() => callCount++); + controller.showCurrencySymbol = true; + expect(callCount, 0); + }); + + test('typing with hidden symbol formats without symbol', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + showCurrencySymbol: false, + ); + controller.value = const TextEditingValue( + text: '5000', + selection: TextSelection.collapsed(offset: 4), + ); + expect(controller.text, '5,000'); + expect(controller.number, 5000); + }); + + test('hidden symbol with negative number', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + showCurrencySymbol: false, + value: -42, + ); + expect(controller.text, '-42'); + expect(controller.number, -42); + }); + + test('hidden symbol with trailing locale negative', () { + final controller = CurrencyEditingController( + locale: 'ru', + currencyName: 'RUB', + showCurrencySymbol: false, + value: -500, + ); + expect(controller.text, '-500'); + expect(controller.number, -500); + }); + + test('default is true', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + expect(controller.showCurrencySymbol, isTrue); + }); + + test('getter returns current value', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + expect(controller.showCurrencySymbol, isTrue); + controller.showCurrencySymbol = false; + expect(controller.showCurrencySymbol, isFalse); + }); + + test('with null value keeps null', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + showCurrencySymbol: false, + ); + expect(controller.number, isNull); + expect(controller.text, ''); + }); + }); + + group('CurrencyEditingController currencySymbolPosition', () { + test('USD in en locale is prefix', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + expect( + controller.currencySymbolPosition, + CurrencySymbolPosition.prefix, + ); + }); + + test('EUR in de locale is suffix', () { + final controller = CurrencyEditingController( + locale: 'de', + currencyName: 'EUR', + ); + expect( + controller.currencySymbolPosition, + CurrencySymbolPosition.suffix, + ); + }); + + test('RUB in ru locale is suffix', () { + final controller = CurrencyEditingController( + locale: 'ru', + currencyName: 'RUB', + ); + expect( + controller.currencySymbolPosition, + CurrencySymbolPosition.suffix, + ); + }); + + test('JPY in ja locale is prefix', () { + final controller = CurrencyEditingController( + locale: 'ja', + currencyName: 'JPY', + ); + expect( + controller.currencySymbolPosition, + CurrencySymbolPosition.prefix, + ); + }); + + test('position updates when locale changes', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'EUR', + ); + expect( + controller.currencySymbolPosition, + CurrencySymbolPosition.prefix, + ); + controller.locale = 'de'; + expect( + controller.currencySymbolPosition, + CurrencySymbolPosition.suffix, + ); + }); + }); + + group('CurrencyEditingController resolvedCurrencySymbol', () { + test('returns resolved symbol when none specified', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + expect(controller.resolvedCurrencySymbol, '\$'); + }); + + test('returns custom symbol when specified', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + currencySymbol: '£', + ); + expect(controller.resolvedCurrencySymbol, '£'); + }); + + test('updates when currencyName changes', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + expect(controller.resolvedCurrencySymbol, '\$'); + controller.currencyName = 'EUR'; + expect(controller.resolvedCurrencySymbol, '€'); + }); + + test('updates when currencySymbol changes', () { + final controller = CurrencyEditingController( + locale: 'en', + currencyName: 'USD', + ); + expect(controller.resolvedCurrencySymbol, '\$'); + controller.currencySymbol = '₺'; + expect(controller.resolvedCurrencySymbol, '₺'); + }); + + test('JPY resolves to yen symbol', () { + final controller = CurrencyEditingController( + locale: 'ja', + currencyName: 'JPY', + ); + expect(controller.resolvedCurrencySymbol, '¥'); + }); + }); + group('mutable DecimalEditingController options', () { test('changing minimalFractionDigits reformats', () { final controller = DecimalEditingController(