From 7c63784631850e0cd5ce90e2416ff6b2d4f3753e Mon Sep 17 00:00:00 2001 From: Bernardo Veras Date: Wed, 25 Feb 2026 14:15:47 -0300 Subject: [PATCH] feat: add showCountryCode parameter to PhoneInputFormatter Add a showCountryCode flag (default: true) to PhoneInputFormatter, formatAsPhoneNumber, and isPhoneValid for API consistency. When set to false, the formatted output displays only the local part of the phone number (e.g. '(11) 99999-9999' instead of '+55 (11) 99999-9999') while the unmasked getter always returns the full international number. Also fixes a latent negative-offset assertion crash in formatEditUpdate by clamping selectionEnd, and extracts the duplicated strip-and-trim logic into a private _resolveCountryCodeDisplay helper. --- README.md | 41 +++++++++ lib/formatters/phone_input_formatter.dart | 93 +++++++++++++++---- test/flutter_multi_formatter_test.dart | 105 ++++++++++++++++++++++ 3 files changed, 223 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2933237..3363672 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,47 @@ CountryDropdown( ``` +### Hiding the country code in formatted output + +By default, `PhoneInputFormatter` includes the country code prefix in the formatted value (e.g. `+55 (11) 99999-9999`). Set `showCountryCode: false` to display only the local part of the number while the `unmasked` getter still returns the full international number. + +> **Input contract:** The value fed to the formatter must still include the full international number (e.g. `+5511999999999`) so that the country can be auto-detected. Only the *display* is stripped — the digits used for detection are always the full number. + +```dart +// Default — country code is visible +PhoneInputFormatter( + showCountryCode: true, // default +) +// input '+5511999999999' → masked: '+55 (11) 99999-9999' + +// Hide country code +PhoneInputFormatter( + showCountryCode: false, +) +// input '+5511999999999' → masked: '(11) 99999-9999' +// → unmasked: '+5511999999999' ← full number preserved +``` + +The same flag is available on the `formatAsPhoneNumber` top-level function: + +```dart +formatAsPhoneNumber('+5511999999999', showCountryCode: false); +// → '(11) 99999-9999' +``` + +**Using with `defaultCountryCode`:** when `defaultCountryCode` is already set the country code is hidden from the mask by default, so `showCountryCode` has no additional effect on the display. However, setting `showCountryCode: false` alongside `defaultCountryCode` ensures that `unmasked` returns the full international number (country code prepended): + +```dart +PhoneInputFormatter( + defaultCountryCode: 'BR', + showCountryCode: false, +) +// input '11999999999' → masked: '(11) 99999-9999' +// → unmasked: '+5511999999999' +``` + +> **Note:** `unmasked` always returns the full international number (with `+` and country code) regardless of `showCountryCode`. + ### Formatting a credit / debit card diff --git a/lib/formatters/phone_input_formatter.dart b/lib/formatters/phone_input_formatter.dart index b14c528..2edf68c 100644 --- a/lib/formatters/phone_input_formatter.dart +++ b/lib/formatters/phone_input_formatter.dart @@ -37,6 +37,12 @@ class PhoneInputFormatter extends TextInputFormatter { final bool shouldCorrectNumber; final String? defaultCountryCode; + /// if [showCountryCode] is false, the formatted output will not include + /// the country code prefix (e.g. `(11) 99999-9999` instead of + /// `+55 (11) 99999-9999`). The [unmasked] getter will still return the + /// full international number with country code. + final bool showCountryCode; + PhoneCountryData? _countryData; String _lastValue = ''; @@ -51,25 +57,39 @@ class PhoneInputFormatter extends TextInputFormatter { /// [defaultCountryCode] if you set a default country code, /// the phone will be formatted according to its country mask /// and no leading country code will be present in the masked value + /// [showCountryCode] if false, the country code prefix is hidden from the + /// formatted output while [unmasked] still returns the full international number PhoneInputFormatter({ this.onCountrySelected, this.allowEndlessPhone = false, this.shouldCorrectNumber = true, this.defaultCountryCode, + this.showCountryCode = true, }); String get masked => _lastValue; - String get unmasked => '+${toNumericString( - _lastValue, - allowHyphen: false, - allowAllZeroes: true, - )}'; + String get unmasked { + final digits = toNumericString( + _lastValue, + allowHyphen: false, + allowAllZeroes: true, + ); + if (!showCountryCode && _countryData?.phoneCode != null) { + return '+${_countryData!.phoneCode}$digits'; + } + return '+$digits'; + } - bool get isFilled => isPhoneValid( - masked, - defaultCountryCode: defaultCountryCode, - ); + bool get isFilled { + if (!showCountryCode && defaultCountryCode == null) { + return isPhoneValid(unmasked); + } + return isPhoneValid( + masked, + defaultCountryCode: defaultCountryCode, + ); + } @override TextEditingValue formatEditUpdate( @@ -130,7 +150,8 @@ class PhoneInputFormatter extends TextInputFormatter { } final endOffset = newValue.text.length - newValue.selection.end; - final selectionEnd = maskedValue.length - endOffset; + final selectionEnd = + (maskedValue.length - endOffset).clamp(0, maskedValue.length); _lastValue = maskedValue; return TextEditingValue( @@ -175,10 +196,16 @@ class PhoneInputFormatter extends TextInputFormatter { } } if (_countryData != null) { - return _formatByMask( + final resolved = _resolveCountryCodeDisplay( + _countryData!, + defaultCountryCode, + showCountryCode, numericString, - _countryData!.getCorrectMask(defaultCountryCode), - _countryData!.getCorrectAltMasks(defaultCountryCode), + ); + return _formatByMask( + resolved.inputForMask, + _countryData!.getCorrectMask(resolved.effectiveCountryCode), + _countryData!.getCorrectAltMasks(resolved.effectiveCountryCode), 0, allowEndlessPhone, ); @@ -270,6 +297,33 @@ class PhoneInputFormatter extends TextInputFormatter { } } +class _PhoneDisplayResolution { + final String? effectiveCountryCode; + final String inputForMask; + const _PhoneDisplayResolution(this.effectiveCountryCode, this.inputForMask); +} + +/// When [showCountryCode] is false and no [defaultCountryCode] is provided, +/// selects the trimmed (no-prefix) mask and strips the phone-code digits from +/// [input] so the formatter displays only the local portion of the number. +_PhoneDisplayResolution _resolveCountryCodeDisplay( + PhoneCountryData countryData, + String? defaultCountryCode, + bool showCountryCode, + String input, +) { + String? effectiveCountryCode = defaultCountryCode; + String inputForMask = input; + if (!showCountryCode && effectiveCountryCode == null) { + effectiveCountryCode = countryData.countryCode; + final phoneCode = countryData.phoneCode; + if (phoneCode != null && input.startsWith(phoneCode)) { + inputForMask = input.substring(phoneCode.length); + } + } + return _PhoneDisplayResolution(effectiveCountryCode, inputForMask); +} + bool isPhoneValid( String phone, { bool allowEndlessPhone = false, @@ -331,6 +385,7 @@ String? formatAsPhoneNumber( bool allowEndlessPhone = false, String? defaultMask, String? defaultCountryCode, + bool showCountryCode = true, }) { if (!isPhoneValid( phone, @@ -366,10 +421,16 @@ String? formatAsPhoneNumber( } if (countryData != null) { - return _formatByMask( + final resolved = _resolveCountryCodeDisplay( + countryData, + defaultCountryCode, + showCountryCode, phone, - countryData.getCorrectMask(defaultCountryCode), - countryData.getCorrectAltMasks(defaultCountryCode), + ); + return _formatByMask( + resolved.inputForMask, + countryData.getCorrectMask(resolved.effectiveCountryCode), + countryData.getCorrectAltMasks(resolved.effectiveCountryCode), 0, allowEndlessPhone, ); diff --git a/test/flutter_multi_formatter_test.dart b/test/flutter_multi_formatter_test.dart index b28e54a..1452d0f 100644 --- a/test/flutter_multi_formatter_test.dart +++ b/test/flutter_multi_formatter_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../lib/flutter_multi_formatter.dart'; @@ -169,4 +170,108 @@ void main() { }); }); }); + + group('showCountryCode', () { + test('default behavior (showCountryCode: true) shows country code', () { + final inputNumber = '+5511999999999'; + final formattedNumber = PhoneInputFormatter() + .formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue(text: inputNumber), + ) + .text; + expect(formattedNumber, '+55 (11) 99999-9999'); + }); + + test('showCountryCode: false hides country code for BR number', () { + final inputNumber = '+5511999999999'; + final formattedNumber = + PhoneInputFormatter(showCountryCode: false) + .formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue(text: inputNumber), + ) + .text; + expect(formattedNumber, '(11) 99999-9999'); + }); + + test('showCountryCode: false hides country code for US number', () { + final inputNumber = '+14444444444'; + final formattedNumber = + PhoneInputFormatter(showCountryCode: false) + .formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue(text: inputNumber), + ) + .text; + expect(formattedNumber, '(444) 444 4444'); + }); + + test('showCountryCode: false handles partial input', () { + final inputNumber = '+5511'; + // An explicit selection is required here because with showCountryCode: false + // the masked output is shorter than the raw input (country-code digits are + // hidden), so the default invalid selection (end = -1) would produce a + // negative selectionEnd and trigger a Flutter assertion. + final formattedNumber = + PhoneInputFormatter(showCountryCode: false) + .formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue( + text: inputNumber, + selection: TextSelection.collapsed(offset: inputNumber.length), + ), + ) + .text; + expect(formattedNumber, '(11'); + }); + + test('unmasked getter returns full international number when showCountryCode: false', () { + final inputNumber = '+5511999999999'; + final formatter = PhoneInputFormatter(showCountryCode: false); + formatter.formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue(text: inputNumber), + ); + expect(formatter.unmasked, '+5511999999999'); + }); + + test('formatAsPhoneNumber with showCountryCode: false hides country code', () { + final result = formatAsPhoneNumber( + '+5511999999999', + showCountryCode: false, + ); + expect(result, '(11) 99999-9999'); + }); + + test('isPhoneValid still works for full number', () { + expect(isPhoneValid('+5511999999999'), isTrue); + }); + + group('combined with defaultCountryCode', () { + // When defaultCountryCode is set, showCountryCode: false has no additional + // effect on the masked output (defaultCountryCode already hides the prefix), + // but unmasked should still return the full international number. + test('masked output is unaffected — defaultCountryCode already hides prefix', () { + final formattedNumber = + PhoneInputFormatter(defaultCountryCode: 'BR', showCountryCode: false) + .formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue(text: '11999999999'), + ) + .text; + expect(formattedNumber, '(11) 99999-9999'); + }); + + test('unmasked returns full international number', () { + final formatter = + PhoneInputFormatter(defaultCountryCode: 'BR', showCountryCode: false); + formatter.formatEditUpdate( + TextEditingValue(text: ''), + TextEditingValue(text: '11999999999'), + ); + expect(formatter.unmasked, '+5511999999999'); + }); + }); + }); }