diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 7ed95d199..59e30edf2 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -286,6 +286,7 @@ "registerEmailVerificationFailed": "Sie haben Ihre E-Mail noch nicht bestätigt.", "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", + "registerPhoneNumberLeadingZero": "Ohne führende Null eingeben", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", "registerPhoneNumberPrefixFormat": "Vorwahl muss aus 1 bis 3 Ziffern bestehen", "registerPhoneNumberPrefixInvalid": "Vorwahl ist erforderlich", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 44db6efe9..fe666c307 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -286,6 +286,7 @@ "registerEmailVerificationFailed": "You have not yet confirmed your email address.", "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", + "registerPhoneNumberLeadingZero": "Leave out the leading zero", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", "registerPhoneNumberPrefixFormat": "Country code must be 1 to 3 digits", "registerPhoneNumberPrefixInvalid": "Country code is required", diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index d2f10836e..920c68ca4 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -8056,7 +8056,7 @@

78Telefonnummer-Eingabe

/>
- Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das Vorwahl-Dropdown mit der Standardvorwahl +41 (Schweiz, alternativ +49 für Deutschland) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. + Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl +41 (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. Eine führende Null wird beim Zusammenbauen der Nummer entfernt; bleibt trotzdem eine übrig, meldet das Feld Ohne führende Null eingeben.
diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 023bd6d99..02ce210e3 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dlibphonenumber/dlibphonenumber.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; @@ -16,6 +17,10 @@ class _PhoneNumberFieldState extends State { // Used only to decompose a seeded value. Input is free-form and not limited to this list. // `+41` stays first: it is the fallback default (`prefix ??= prefixes.first`). final prefixes = ['+41', '+49', '+43', '+423']; + // Canonicalization uses libphonenumber metadata, not this list. These main-market + // prefixes remain only to report a surviving second leading zero in the field + // instead of letting the API return a 400. + static const _trunkZeroPrefixes = ['+41', '+49', '+43']; String? prefix; String? number; @@ -40,12 +45,29 @@ class _PhoneNumberFieldState extends State { // what the user typed. Fall back to the first prefix; the number field starts empty, // so the validator still blocks submit until it is re-entered. prefix ??= prefixes.first; + + // Canonicalization here only applies when the loop above split the seed. + // An unrecognized dial code leaves number null, so updatePhoneNumber() writes nothing. + updatePhoneNumber(); } void updatePhoneNumber() { - if (prefix != null && number != null) { - final value = '$prefix$number'; - widget.controller.value = value; + final prefix = this.prefix; + final number = this.number; + if (prefix == null || number == null) return; + + widget.controller.value = _canonicalize('$prefix$number'); + } + + static String _canonicalize(String value) { + try { + final util = PhoneNumberUtil.instance; + return util.format(util.parse(value, null), PhoneNumberFormat.e164); + } on NumberParseException { + // `parse` throws NumberParseException for incomplete input while the user is + // typing; preserve the raw value and let the API decide validity on submit. + // Other exceptions are intentionally not caught. + return value; } } @@ -115,7 +137,14 @@ class _PhoneNumberFieldState extends State { if (!RegExp(r'^[0-9]+$').hasMatch(value)) { return S.of(context).registerPhoneNumberOnlyDigits; } - // Length is validated by the API (libphonenumber); the client + final canonical = _canonicalize('$prefix$value'); + if (_trunkZeroPrefixes.any( + (countryPrefix) => canonical.startsWith('${countryPrefix}0'), + )) { + return S.of(context).registerPhoneNumberLeadingZero; + } + // Apart from the explicit trunk-zero canonicality check above, + // length is validated by the API (libphonenumber); the client // must not gate on it — see CONTRIBUTING "the API decides". return null; }, diff --git a/pubspec.lock b/pubspec.lock index a68f0b590..c9a07d60f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -298,6 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + dlibphonenumber: + dependency: "direct main" + description: + name: dlibphonenumber + sha256: b467588e1d09972b5b650517de484c6f9beed23a27dcc78dbde901f99db8899e + url: "https://pub.dev" + source: hosted + version: "1.1.70" drift: dependency: "direct main" description: @@ -1239,6 +1247,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" + url: "https://pub.dev" + source: hosted + version: "6.0.0" provider: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 775cd3438..d7e3088e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: clock: ^1.1.2 collection: ^1.19.0 convert: ^3.1.2 + dlibphonenumber: ^1.1.70 drift: ^2.32.1 eth_sig_util_plus: ^0.0.10 eip7702: diff --git a/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error.png b/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error.png new file mode 100644 index 000000000..edb90e2e3 Binary files /dev/null and b/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error.png differ diff --git a/test/goldens/widgets/form/phone_number_field_golden_test.dart b/test/goldens/widgets/form/phone_number_field_golden_test.dart index 12562fd4a..7aa337f13 100644 --- a/test/goldens/widgets/form/phone_number_field_golden_test.dart +++ b/test/goldens/widgets/form/phone_number_field_golden_test.dart @@ -21,5 +21,35 @@ void main() { ), ), ); + + // This is the longest message the field displays. + // Alchemist's wrapper pushes a MaterialPageRoute; the default + // pumpBeforeTest (precacheImages) settles it. Replacing that default with + // validate() plus a single pump() runs Form.validate() before FormField.build + // has registered the fields, so _fields is empty and the error never paints. + final formKey = GlobalKey(); + goldenTest( + 'leading-zero error phone number field', + fileName: 'phone_number_field_leading_zero_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + }, + builder: () => wrapForGolden( + Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: formKey, + child: PhoneNumberField( + controller: ValueNotifier('+4100791234567'), + ), + ), + ), + ), + ), + ); }); } diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 656657bb4..6b4f968cd 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -98,14 +98,16 @@ void main() { ); }); - // The client performs format hygiene only (non-empty + digits). It must not - // gate on length: the API validates the number with libphonenumber, so the - // app accepts any non-empty, digits-only national part regardless of length - // and lets the backend accept or reject it (CONTRIBUTING: "the API decides… - // the app must not block it pre-emptively"). These cases guard against a - // length gate being re-introduced. - testWidgets('accepts a short +41 national number and defers the length to the API', - (tester) async { + // The client enforces basic format (non-empty + digits) and the explicit + // CH/DE/AT trunk-zero canonicality invariant. All other phone validity, + // including length and dial-code existence, remains backend-owned, so the + // app accepts non-empty, digits-only national parts regardless of length + // and lets the backend accept or reject them (CONTRIBUTING: "the API + // decides"; the app must not block them pre-emptively). These cases guard + // against a length gate being re-introduced. + testWidgets('accepts a short +41 national number and defers the length to the API', ( + tester, + ) async { final harness = await _pumpPhoneField(tester); final isValid = await _enterAndValidate(tester, harness, '12345'); @@ -123,8 +125,227 @@ void main() { expect(isValid, isTrue); }); - testWidgets('accepts a 9-digit +49 national number (valid per the API, not a length error)', - (tester) async { + testWidgets('strips a leading Swiss trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '0791234567'); + + expect(harness.controller.value, '+41791234567'); + expect(isValid, isTrue); + }); + + testWidgets('canonicalizes a Swiss number when the prefix contains extra digits', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '410'); + final isValid = await _enterAndValidate(tester, harness, '791234567'); + + expect(harness.controller.value, '+41791234567'); + expect(isValid, isTrue); + }); + + testWidgets('canonicalizes a Swiss number when the national field contains the prefix digit', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '4'); + final isValid = await _enterAndValidate(tester, harness, '10791234567'); + + expect(harness.controller.value, '+41791234567'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading German trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+49'); + + final isValid = await _enterAndValidate(tester, harness, '0691234567'); + + expect(harness.controller.value, '+49691234567'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading Austrian trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '43'); + final isValid = await _enterAndValidate(tester, harness, '06641234567'); + + expect(harness.controller.value, '+436641234567'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading French trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '33'); + final isValid = await _enterAndValidate(tester, harness, '0612345678'); + + expect(harness.controller.value, '+33612345678'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading UK trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '44'); + final isValid = await _enterAndValidate(tester, harness, '07911123456'); + + expect(harness.controller.value, '+447911123456'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading Dutch trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '31'); + final isValid = await _enterAndValidate(tester, harness, '0612345678'); + + expect(harness.controller.value, '+31612345678'); + expect(isValid, isTrue); + }); + + testWidgets('keeps a leading Italian zero in the stored number', (tester) async { + // For +39 the leading 0 is significant. Stripping it would make landlines + // such as 0666982 invalid. + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '39'); + final isValid = await _enterAndValidate(tester, harness, '0666982'); + + expect(harness.controller.value, '+390666982'); + expect(isValid, isTrue); + }); + + testWidgets('keeps a leading Liechtenstein zero in the stored number', (tester) async { + // Length check, not a missing trunk prefix: stripping 0 would leave a + // length that is not possible for LI, so libphonenumber keeps the raw value. + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '423'); + final isValid = await _enterAndValidate(tester, harness, '0123456'); + + expect(harness.controller.value, '+4230123456'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading Liechtenstein trunk zero when the result has a valid length', ( + tester, + ) async { + // LI has a trunk prefix; the zero is stripped once the result has a valid length. + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '423'); + final isValid = await _enterAndValidate(tester, harness, '07912345'); + + expect(harness.controller.value, '+4237912345'); + expect(isValid, isTrue); + }); + + testWidgets('does not strip a zero that is not at the start of the national number', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '790123456'); + + expect(harness.controller.value, '+41790123456'); + expect(isValid, isTrue); + }); + + testWidgets('preserves incomplete input when phone-number parsing fails', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '7'); + + expect(harness.controller.value, '+417'); + expect(isValid, isTrue); + }); + + testWidgets('rejects multiple leading trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '00791234567'); + + expect(harness.controller.value, '+4100791234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), + findsOneWidget, + ); + }); + + testWidgets('rejects multiple leading German trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '49'); + final isValid = await _enterAndValidate(tester, harness, '00691234567'); + + // One of the two zeros is stripped; the surviving one leaves +490… and trips the validator. + expect(harness.controller.value, '+490691234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), + findsOneWidget, + ); + }); + + testWidgets('rejects multiple leading Austrian trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '43'); + final isValid = await _enterAndValidate(tester, harness, '006641234567'); + + // One of the two zeros is stripped; the surviving one leaves +430… and trips the validator. + expect(harness.controller.value, '+4306641234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), + findsOneWidget, + ); + }); + + testWidgets('strips a leading trunk zero when the country prefix changes', (tester) async { + final harness = await _pumpPhoneField(tester); + await tester.enterText(_numberField(), '0791234567'); + await tester.pump(); + + await tester.enterText(_prefixField(), '49'); + await tester.pump(); + + expect(harness.controller.value, '+49791234567'); + }); + + testWidgets('strips a leading trunk zero from a pre-filled value without user interaction', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+410791234567'); + await tester.pump(); + + expect(harness.controller.value, '+41791234567'); + }); + + testWidgets('rejects a pre-filled number with multiple leading trunk zeros', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+4100791234567'); + + final isValid = harness.formKey.currentState!.validate(); + await tester.pump(); + + expect(harness.controller.value, '+4100791234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), + findsOneWidget, + ); + }); + + testWidgets('accepts a 9-digit +49 national number (valid per the API, not a length error)', ( + tester, + ) async { // A 9-digit German national number (e.g. a Frankfurt landline, 069 …) is // valid for libphonenumber; the client must not reject it on length. final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+49');