diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_phone_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_phone_field.dart index 2cb57fa159..44f0f66800 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_phone_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_phone_field.dart @@ -5,6 +5,7 @@ import 'package:amplify_authenticator/src/keys.dart'; import 'package:amplify_authenticator/src/l10n/dial_code_resolver.dart'; import 'package:amplify_authenticator/src/utils/breakpoint.dart'; import 'package:amplify_authenticator/src/utils/dial_code.dart'; +import 'package:amplify_authenticator/src/utils/dial_code_to_length.dart'; import 'package:amplify_authenticator/src/widgets/authenticator_input_config.dart'; import 'package:amplify_authenticator/src/widgets/form_field.dart'; import 'package:collection/collection.dart'; @@ -47,12 +48,22 @@ mixin AuthenticatorPhoneFieldMixin countryPhoneNumberLengths[prefix]!) { + phoneNumber = phoneNumber.substring(prefix.length - 1); + } + } + return phoneNumber; + } + + String displayPhoneNumber(String? phoneNumber) { + phoneNumber = phoneNumber ?? ''; + final prefix = '+${state.dialCode.value}'; + if (phoneNumber.startsWith(prefix)) { + phoneNumber = phoneNumber.substring(prefix.length); + } + // this is to handle the case where the user may errantly input their dial code again in their phone number + // we make sure the user's phone number doesn't naturally just start with their dial code by checking if the number exceeds the maximum phone length of the country's phone number scheme before truncating it + if (phoneNumber.startsWith(prefix.substring(1))) { + if (countryPhoneNumberLengths.containsKey(prefix) && + phoneNumber.length > countryPhoneNumberLengths[prefix]!) { + phoneNumber = phoneNumber.substring(prefix.length - 1); + } + } + return phoneNumber; + } + + String displayPhoneNumber(String? phoneNumber) { + phoneNumber = phoneNumber ?? ''; + final prefix = '+${state.dialCode.value}'; + if (phoneNumber.startsWith(prefix)) { + phoneNumber = phoneNumber.substring(prefix.length); + } + // this is to handle the case where the user may errantly input their dial code again in their phone number + // we make sure the user's phone number doesn't naturally just start with their dial code by checking if the number exceeds the maximum phone length of the country's phone number scheme before truncating it + if (!phoneNumber.startsWith(prefix.substring(1))) { + return phoneNumber; + } + final prefixLength = countryPhoneNumberLengths[prefix]; + if (prefixLength == null || phoneNumber.length <= prefixLength) { + return phoneNumber; + } + return phoneNumber.substring(prefix.length - 1); + } + @override FormFieldValidator get validator { switch (selectedUsernameType) { @@ -183,7 +236,7 @@ mixin AuthenticatorUsernameField countryPhoneNumberLengths = { + '+1': 10, // United States, Canada, and other NANP countries + '+7': 10, // Russia, Kazakhstan + '+20': 10, // Egypt + '+27': 9, // South Africa + '+30': 10, // Greece + '+31': 9, // Netherlands + '+32': 9, // Belgium + '+33': 9, // France + '+34': 9, // Spain + '+36': 9, // Hungary + '+39': 10, // Italy + '+40': 9, // Romania + '+44': 10, // United Kingdom + '+45': 8, // Denmark + '+46': 9, // Sweden + '+47': 8, // Norway + '+48': 9, // Poland + '+49': 11, // Germany + '+51': 9, // Peru + '+52': 10, // Mexico + '+53': 8, // Cuba + '+54': 10, // Argentina + '+55': 11, // Brazil + '+56': 9, // Chile + '+57': 10, // Colombia + '+58': 10, // Venezuela + '+60': 10, // Malaysia + '+61': 9, // Australia + '+62': 11, // Indonesia + '+63': 10, // Philippines + '+64': 9, // New Zealand + '+65': 8, // Singapore + '+66': 9, // Thailand + '+81': 10, // Japan + '+82': 10, // South Korea + '+84': 10, // Vietnam + '+86': 11, // China + '+90': 11, // Turkey + '+91': 10, // India + '+92': 10, // Pakistan + '+93': 9, // Afghanistan + '+94': 10, // Sri Lanka + '+95': 9, // Myanmar + '+98': 10, // Iran + '+211': 9, // South Sudan + '+212': 9, // Morocco + '+213': 9, // Algeria + '+216': 8, // Tunisia + '+218': 10, // Libya + '+220': 7, // Gambia + '+221': 9, // Senegal + '+222': 9, // Mauritania + '+223': 8, // Mali + '+224': 9, // Guinea + '+225': 10, // Ivory Coast + '+226': 8, // Burkina Faso + '+227': 8, // Niger + '+228': 8, // Togo + '+229': 8, // Benin + '+230': 7, // Mauritius + '+231': 9, // Liberia + '+232': 8, // Sierra Leone + '+233': 9, // Ghana + '+234': 10, // Nigeria + '+235': 8, // Chad + '+236': 8, // Central African Republic + '+237': 9, // Cameroon + '+238': 7, // Cape Verde + '+239': 7, // São Tomé and Príncipe + '+240': 9, // Equatorial Guinea + '+241': 9, // Gabon + '+242': 9, // Republic of the Congo + '+243': 9, // DR Congo + '+244': 9, // Angola + '+245': 7, // Guinea-Bissau + '+248': 7, // Seychelles + '+249': 9, // Sudan + '+250': 9, // Rwanda + '+251': 9, // Ethiopia + '+252': 8, // Somalia + '+253': 8, // Djibouti + '+254': 10, // Kenya + '+255': 9, // Tanzania + '+256': 9, // Uganda + '+257': 8, // Burundi + '+258': 9, // Mozambique + '+260': 9, // Zambia + '+261': 9, // Madagascar + '+262': 9, // Réunion + '+263': 9, // Zimbabwe + '+264': 9, // Namibia + '+265': 9, // Malawi + '+266': 8, // Lesotho + '+267': 8, // Botswana + '+268': 8, // Eswatini (Swaziland) + '+269': 7, // Comoros + '+290': 4, // Saint Helena + '+291': 7, // Eritrea + '+297': 7, // Aruba + '+298': 6, // Faroe Islands + '+299': 6, // Greenland + '+350': 8, // Gibraltar + '+351': 9, // Portugal + '+352': 9, // Luxembourg + '+353': 9, // Ireland + '+354': 7, // Iceland + '+355': 9, // Albania + '+356': 8, // Malta + '+357': 8, // Cyprus + '+358': 10, // Finland + '+359': 9, // Bulgaria + '+370': 8, // Lithuania + '+371': 8, // Latvia + '+372': 8, // Estonia + '+373': 8, // Moldova + '+374': 8, // Armenia + '+375': 9, // Belarus + '+376': 6, // Andorra + '+377': 8, // Monaco + '+378': 10, // San Marino + '+380': 9, // Ukraine + '+381': 9, // Serbia + '+382': 9, // Montenegro + '+385': 9, // Croatia + '+386': 9, // Slovenia + '+387': 8, // Bosnia and Herzegovina + '+389': 8, // North Macedonia + '+420': 9, // Czech Republic + '+421': 9, // Slovakia + '+423': 7, // Liechtenstein + '+500': 5, // Falkland Islands + '+501': 7, // Belize + '+502': 8, // Guatemala + '+503': 8, // El Salvador + '+504': 8, // Honduras + '+505': 8, // Nicaragua + '+506': 8, // Costa Rica + '+507': 7, // Panama + '+509': 8, // Haiti + '+590': 9, // Guadeloupe + '+591': 8, // Bolivia + '+592': 7, // Guyana + '+593': 9, // Ecuador + '+594': 9, // French Guiana + '+595': 9, // Paraguay + '+596': 9, // Martinique + '+597': 7, // Suriname + '+598': 9, // Uruguay + '+670': 7, // East Timor + '+672': 6, // Australian External Territories + '+673': 7, // Brunei + '+674': 7, // Nauru + '+675': 8, // Papua New Guinea + '+676': 5, // Tonga + '+677': 7, // Solomon Islands + '+678': 7, // Vanuatu + '+679': 7, // Fiji + '+680': 7, // Palau + '+681': 6, // Wallis and Futuna + '+682': 5, // Cook Islands + '+683': 4, // Niue + '+685': 7, // Samoa + '+686': 8, // Kiribati + '+687': 6, // New Caledonia + '+688': 5, // Tuvalu + '+689': 6, // French Polynesia + '+690': 4, // Tokelau + '+691': 7, // Micronesia + '+692': 7, // Marshall Islands + '+850': 10, // North Korea + '+852': 8, // Hong Kong + '+853': 8, // Macau + '+855': 9, // Cambodia + '+856': 9, // Laos + '+880': 10, // Bangladesh + '+886': 9, // Taiwan + '+960': 7, // Maldives + '+961': 8, // Lebanon + '+962': 9, // Jordan + '+963': 9, // Syria + '+964': 10, // Iraq + '+965': 8, // Kuwait + '+966': 9, // Saudi Arabia + '+967': 9, // Yemen + '+968': 8, // Oman + '+970': 9, // Palestine + '+971': 9, // United Arab Emirates + '+972': 9, // Israel + '+973': 8, // Bahrain + '+974': 8, // Qatar + '+975': 8, // Bhutan + '+976': 8, // Mongolia + '+977': 10, // Nepal + '+992': 9, // Tajikistan + '+993': 8, // Turkmenistan + '+994': 9, // Azerbaijan + '+995': 9, // Georgia + '+996': 9, // Kyrgyzstan + '+998': 9, // Uzbekistan +}; diff --git a/packages/authenticator/amplify_authenticator/lib/src/utils/validators.dart b/packages/authenticator/amplify_authenticator/lib/src/utils/validators.dart index 03cc103d1c..b2f67acddd 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/utils/validators.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/utils/validators.dart @@ -14,7 +14,7 @@ import 'package:flutter/material.dart'; */ final usernameRegex = RegExp(r'^\S+$'); final emailRegex = RegExp(r'^\S+@\S+$'); -final phoneNumberRegex = RegExp(r'^\+\d+$'); +final phoneNumberRegex = RegExp(r'^\d+$'); final _codeRegex = RegExp(r'^\d{6}$'); final _uppercase = RegExp(r'[A-Z]'); final _lowercase = RegExp(r'[a-z]'); @@ -154,8 +154,8 @@ FormFieldValidator validatePhoneNumber({ InputResolverKey.phoneNumberEmpty, ); } - phoneNumber = phoneNumber.trim(); - if (!phoneNumberRegex.hasMatch(phoneNumber)) { + final formattedNumber = phoneNumber.trim(); + if (!phoneNumberRegex.hasMatch(formattedNumber)) { return inputResolver.resolve(context, InputResolverKey.phoneNumberFormat); } return null; diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart index 22d1253f39..fa5ab9ccc4 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart @@ -568,7 +568,7 @@ class _ConfirmSignInPhoneFieldState extends _ConfirmSignInTextFieldState @override FormFieldValidator get validator { return (phoneNumber) { - phoneNumber = formatPhoneNumber(phoneNumber); + phoneNumber = displayPhoneNumber(phoneNumber); return validatePhoneNumber( inputResolver: stringResolver.inputs, context: context, diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart index b3cb32a3bd..c9dac65ebd 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart @@ -86,11 +86,12 @@ class _AuthenticatorPhoneFieldState @override FormFieldValidator get validator { return (String? phoneNumber) { - phoneNumber = formatPhoneNumber(phoneNumber); final validator = widget.validator; if (validator != null) { + phoneNumber = formatPhoneNumber(phoneNumber); return validator(phoneNumber); } + phoneNumber = displayPhoneNumber(phoneNumber); return validatePhoneNumber( inputResolver: stringResolver.inputs, context: context, diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart index d4e0687823..ebf74e1373 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart @@ -738,7 +738,7 @@ class _SignUpPhoneFieldState extends _SignUpTextFieldState @override FormFieldValidator get validator { return (phoneNumber) { - phoneNumber = formatPhoneNumber(phoneNumber); + phoneNumber = displayPhoneNumber(phoneNumber); return validatePhoneNumber( inputResolver: stringResolver.inputs, context: context, diff --git a/packages/authenticator/amplify_authenticator/test/sign_up_form_test.dart b/packages/authenticator/amplify_authenticator/test/sign_up_form_test.dart index 21fc76fe60..e2ddda2805 100644 --- a/packages/authenticator/amplify_authenticator/test/sign_up_form_test.dart +++ b/packages/authenticator/amplify_authenticator/test/sign_up_form_test.dart @@ -6,9 +6,18 @@ import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_authenticator/src/services/amplify_auth_service.dart'; import 'package:amplify_authenticator_test/amplify_authenticator_test.dart'; import 'package:amplify_integration_test/amplify_integration_test.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +class MockState { + final MockDialCode dialCode = MockDialCode(); +} + +class MockDialCode { + String value = ''; +} + class MockAuthService extends Mock implements AmplifyAuthService { String? capturedUsername; @@ -53,6 +62,59 @@ class MockAuthPlugin extends AmplifyAuthCognitoStub { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + group('PhoneNumberFormatter', () { + late MockState state; + late Map countryPhoneNumberLengths; + + setUp(() { + state = MockState(); + countryPhoneNumberLengths = {}; + }); + + String displayPhoneNumber(String? phoneNumber) { + phoneNumber = phoneNumber ?? ''; + final prefix = '+${state.dialCode.value}'; + if (phoneNumber.startsWith(prefix)) { + phoneNumber = phoneNumber.substring(prefix.length); + } + // This is to handle the case where the user may errantly input their dial code again in their phone number + // We make sure the user's phone number doesn't naturally just start with their dial code by checking if the number exceeds the maximum phone length of the country's phone number scheme before truncating it + if (phoneNumber.startsWith(prefix.substring(1))) { + if (countryPhoneNumberLengths.containsKey(prefix) && + phoneNumber.length > countryPhoneNumberLengths[prefix]!) { + phoneNumber = phoneNumber.substring(prefix.length - 1); + } + } + return phoneNumber; + } + + test('removes country code prefix when present', () { + state.dialCode.value = '1'; // US country code + countryPhoneNumberLengths = {'+1': 10}; // US phone number length + const input = '+11234567890'; + const expected = '1234567890'; + final result = displayPhoneNumber(input); + expect(result, equals(expected)); + }); + + test('removes dial code when phone number exceeds max length', () { + state.dialCode.value = '1'; + countryPhoneNumberLengths = {'+1': 10}; + const input = '112345678901'; + const expected = '12345678901'; + final result = displayPhoneNumber(input); + expect(result, equals(expected)); + }); + + test('does not remove dial code when number is within max length', () { + state.dialCode.value = '1'; + countryPhoneNumberLengths = {'+1': 10}; + const input = '1123456789'; + const expected = '1123456789'; + final result = displayPhoneNumber(input); + expect(result, equals(expected)); + }); + }); group('Sign Up View', () { group('form validation', () { testWidgets( @@ -114,6 +176,154 @@ void main() { expect(usernameFieldError, findsOneWidget); }, ); + testWidgets( + 'Truncates the prefix with a phone number that accidentally resubmits the dial code already selected in the dropdown', + (tester) async { + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.phoneNumber(required: true), + SignUpFormField.password(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpPage = SignUpPage(tester: tester); + + await signUpPage.enterPhoneNumber('12235556789'); + + await signUpPage.submitSignUp(); + + await tester.pumpAndSettle(); + final phoneNumber = find + .descendant( + of: signUpPage.phoneField, + matching: find.byType(Text), + ) + .evaluate() + .map((e) => (e.widget as Text).data) + .where((text) => text != null) + .first; + + expect(phoneNumber, equals('2235556789')); + }, + ); + testWidgets( + 'displays message when submitted with empty phone number if the field is required', + (tester) async { + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.phoneNumber(required: true), + SignUpFormField.password(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpPage = SignUpPage(tester: tester); + + await signUpPage.submitSignUp(); + + await tester.pumpAndSettle(); + + Finder findPhoneFieldError() => find.descendant( + of: signUpPage.phoneField, + matching: find.text('Phone Number field must not be blank.'), + ); + + expect(findPhoneFieldError(), findsOneWidget); + + await signUpPage.enterPhoneNumber('1235556789'); + + await signUpPage.submitSignUp(); + + expect(findPhoneFieldError(), findsNothing); + }, + ); + testWidgets( + 'displays message when submitted with empty phone number if the field is required', + (tester) async { + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.phoneNumber(required: true), + SignUpFormField.password(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpPage = SignUpPage(tester: tester); + + await signUpPage.submitSignUp(); + + await tester.pumpAndSettle(); + + Finder findPhoneFieldError() => find.descendant( + of: signUpPage.phoneField, + matching: find.text('Phone Number field must not be blank.'), + ); + + expect(findPhoneFieldError(), findsOneWidget); + + await signUpPage.enterPhoneNumber('1235556789'); + + await signUpPage.submitSignUp(); + + expect(findPhoneFieldError(), findsNothing); + }, + ); + testWidgets( + 'displays message when submitted with empty phone number if the field is required', + (tester) async { + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.phoneNumber(required: true), + SignUpFormField.password(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpPage = SignUpPage(tester: tester); + + await signUpPage.submitSignUp(); + + await tester.pumpAndSettle(); + + Finder findPhoneFieldError() => find.descendant( + of: signUpPage.phoneField, + matching: find.text('Phone Number field must not be blank.'), + ); + + expect(findPhoneFieldError(), findsOneWidget); + + await signUpPage.enterPhoneNumber('1235556789'); + + await signUpPage.submitSignUp(); + + expect(findPhoneFieldError(), findsNothing); + }, + ); testWidgets( 'displays message when submitted with invalid birth date', @@ -260,7 +470,6 @@ void main() { await signUpPage.enterUsername('user@example.com '); await signUpPage.enterPassword('Password123!@#%^'); await signUpPage.enterPasswordConfirmation('Password123!@#%^'); - await signUpPage.submitSignUp(); await tester.pumpAndSettle();