Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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.
5 changes: 4 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -39,6 +40,7 @@ class HomePage extends StatelessWidget {
Tab(text: 'Currency'),
Tab(text: 'Integer'),
Tab(text: 'Decimal'),
Tab(text: 'Prefix/Suffix'),
],
),
),
Expand All @@ -47,6 +49,7 @@ class HomePage extends StatelessWidget {
CurrencyInput(),
IntegerInput(),
DecimalInput(),
ExternalSymbolInput(),
],
),
),
Expand Down
91 changes: 91 additions & 0 deletions example/lib/variants/external_symbol_input.dart
Original file line number Diff line number Diff line change
@@ -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<ExternalSymbolInput> createState() => _ExternalSymbolInputState();
}

class _ExternalSymbolInputState extends State<ExternalSymbolInput> {
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<String>(
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(),
),
),
],
),
),
);
}
}
Binary file added img/currency-prefix.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/currency.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/decimal.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/integer.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed img/screenshot.gif
Binary file not shown.
1 change: 1 addition & 0 deletions lib/number_editing_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 51 additions & 1 deletion lib/src/parsed_number_format.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -62,6 +72,7 @@ class ParsedNumberFormat {
decimalSeparator: decimalSeparator,
groupSeparator: groupSeparator,
allowNegative: allowNegative,
showCurrencySymbol: showCurrencySymbol,
);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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;
Expand Down
37 changes: 36 additions & 1 deletion lib/src/text_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ class CurrencyEditingController extends TextEditingController
String? _currencyName;
String? _currencySymbol;
String? _decimalSeparator;
bool _showCurrencySymbol;

@override
late ParsedNumberFormat _format;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -261,6 +295,7 @@ class CurrencyEditingController extends TextEditingController
decimalSeparator: _decimalSeparator,
groupSeparator: _groupSeparator,
allowNegative: _allowNegative,
showCurrencySymbol: _showCurrencySymbol,
);
}

Expand Down
Loading
Loading