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..51768f757 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -7280,7 +7280,7 @@

68Daten bearbeiten — Formulare

/>
- Das Formular Telefonnummer ändern im Ausgangszustand: eine Länderauswahl mit Vorwahl +41 und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter Speichern. Hier trägt der Nutzer seine neue Telefonnummer ein. + Das Formular Telefonnummer ändern im Ausgangszustand: ein frei eingebbares Vorwahlfeld mit der Standardvorwahl +41 und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter Speichern. Hier trägt der Nutzer seine neue Telefonnummer ein.
@@ -8039,7 +8039,7 @@

78Telefonnummer-Eingabe

- Das Telefonnummer-Feld kombiniert eine Vorwahl-Auswahl mit einem separaten Eingabefeld für die Rufnummer. Es wird bei der Registrierung und in Formularen verwendet, in denen eine Telefonnummer erfasst wird. + Das Telefonnummer-Feld kombiniert ein frei eingebbares Vorwahlfeld (1 bis 3 Ziffern) mit einem separaten Eingabefeld für die Rufnummer. Es wird bei der Registrierung und in Formularen verwendet, in denen eine Telefonnummer erfasst wird.

@@ -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. Die Nummer wird nach den Regeln von libphonenumber in die internationale Form gebracht; wo der Nummernplan keine Verkehrsausscheidungsziffer kennt — bei italienischen Festnetznummern etwa —, bleibt die Null erhalten.
diff --git a/docs/screens.md b/docs/screens.md index d72bcaabc..8d01a75c6 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -16,8 +16,8 @@ Column meaning: screen, or `—` if the screen has no Golden baseline. Each slot is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. The handbook now - covers **all 284 Golden baselines** — every screen **plus every tested - state variant** (Default / Loading / Error / Snackbar / Dropdown / + covers **all 284 Golden baselines mapped to the handbook** — every mapped + screen and mapped state variant (Default / Loading / Error / Snackbar / Dropdown / Validation / Confirm / Success / Failure …), including the areas that were previously absent: Support (email capture, tickets, chat), Settings User-Data and its edit sub-pages, Settings Security, Receive, the BitBox @@ -28,8 +28,10 @@ Column meaning: and `debugAuth` (a `kDebugMode`-only dev tool). Each row lists **all** the slots whose Golden renders that widget — a screen usually has several (its default plus its state variants), so most cells now carry a range rather - than a single anchor. One Golden is a shared form widget rather than a - screen: slot `268` is `PhoneNumberField` under `test/goldens/widgets/form/`. + than a single anchor. Exactly one handbook slot is a shared form widget + rather than a screen: slot `268` is `PhoneNumberField` under + `test/goldens/widgets/form/`. That path also holds two error-state + baselines (German and English) that are not mapped to a handbook slot. Only `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator has no Golden of its own — its states are the individual KYC pages) still carry `—`. Slot ↔ Golden mapping in @@ -117,7 +119,9 @@ Column meaning: | Shared widgets | `PhoneNumberField` | — | — | `268` | 76 screens — 44 routed (`GoRoute`) + 32 non-routed. The table also carries -one shared form-widget baseline (`PhoneNumberField`), which is not a screen. +one shared form-widget slot (`PhoneNumberField`), which is not a screen; +three Golden baselines sit under `test/goldens/widgets/form/`, of which +only slot `268` is a handbook entry. ## Notes 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/goldens/macos/phone_number_field_leading_zero_error_en.png b/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error_en.png new file mode 100644 index 000000000..151bed6ab Binary files /dev/null and b/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error_en.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..fbb5aafe8 100644 --- a/test/goldens/widgets/form/phone_number_field_golden_test.dart +++ b/test/goldens/widgets/form/phone_number_field_golden_test.dart @@ -4,6 +4,29 @@ import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; import '../../../helper/helper.dart'; +Widget _leadingZeroErrorField(GlobalKey formKey, {Locale locale = const Locale('de')}) { + return wrapForGolden( + Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: formKey, + child: PhoneNumberField( + controller: ValueNotifier('+4100791234567'), + ), + ), + ), + ), + locale: locale, + ); +} + +Future _validateThenSettle(WidgetTester tester, GlobalKey formKey) async { + await tester.pumpAndSettle(); + formKey.currentState!.validate(); + await tester.pumpAndSettle(); +} + void main() { group('$PhoneNumberField', () { goldenTest( @@ -21,5 +44,29 @@ void main() { ), ), ); + + // Pins the only error state this PR adds; an earlier wording of the + // message overflowed the field's single error line. + // 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 deErrorKey = GlobalKey(); + goldenTest( + 'leading-zero error phone number field', + fileName: 'phone_number_field_leading_zero_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => _validateThenSettle(tester, deErrorKey), + builder: () => _leadingZeroErrorField(deErrorKey), + ); + + final enErrorKey = GlobalKey(); + goldenTest( + 'leading-zero error phone number field in English', + fileName: 'phone_number_field_leading_zero_error_en', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => _validateThenSettle(tester, enErrorKey), + builder: () => _leadingZeroErrorField(enErrorKey, locale: const Locale('en')), + ); }); } diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index 101a31fce..890c5ecd3 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; @@ -69,6 +70,7 @@ void main() { Registration buildRegistration({ bool swissTaxResidence = true, List? countryAndTINs, + String phoneNumber = '+41 79 000 00 00', }) => Registration( type: RegistrationUserType.human, email: 'AdA@ExAmPlE.COM', @@ -77,7 +79,7 @@ void main() { // round-trip below. firstName: 'Adä', lastName: 'Loveläce', - phoneNumber: '+41 79 000 00 00', + phoneNumber: phoneNumber, birthday: '1815-12-10', nationality: const Country( id: 41, @@ -127,6 +129,93 @@ void main() { ); group('completeRegistration happy path', () { + // Equality of signed and sent alone is not enough: both sides could + // agree on a non-canonical value and the test would still pass. The + // fixture below is the canonical E.164 form (no spaces, no trunk zero) + // — the shape the phone number field already produces before a + // Registration is ever built — so this test pins that value, not just + // that the two sides match each other. + test( + 'signs and transmits phoneNumber as the same exact string', + () async { + const expectedPhoneNumber = '+41791234567'; + Map? body; + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + + await build(client).completeRegistration( + buildRegistration(phoneNumber: expectedPhoneNumber), + ); + + // Pins the exact canonical E.164 value that was transmitted. + expect(body!['phoneNumber'], '+41791234567'); + + // Keep this EIP-712 message in sync with Eip712Signer.signRegistration. + // It catches mutations such as signing '$phoneNumber ' but sending phoneNumber. + final typedData = { + 'types': { + 'EIP712Domain': [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + ], + 'RealUnitUser': [ + {'name': 'email', 'type': 'string'}, + {'name': 'name', 'type': 'string'}, + {'name': 'type', 'type': 'string'}, + {'name': 'phoneNumber', 'type': 'string'}, + {'name': 'birthday', 'type': 'string'}, + {'name': 'nationality', 'type': 'string'}, + {'name': 'addressStreet', 'type': 'string'}, + {'name': 'addressPostalCode', 'type': 'string'}, + {'name': 'addressCity', 'type': 'string'}, + {'name': 'addressCountry', 'type': 'string'}, + {'name': 'swissTaxResidence', 'type': 'bool'}, + {'name': 'registrationDate', 'type': 'string'}, + {'name': 'walletAddress', 'type': 'address'}, + ], + }, + 'primaryType': 'RealUnitUser', + 'domain': { + 'name': 'RealUnitUser', + 'version': '1', + }, + 'message': { + 'email': body!['email'], + 'name': body!['name'], + 'type': body!['type'], + 'phoneNumber': body!['phoneNumber'], + 'birthday': body!['birthday'], + 'nationality': body!['nationality'], + 'addressStreet': body!['addressStreet'], + 'addressPostalCode': body!['addressPostalCode'], + 'addressCity': body!['addressCity'], + 'addressCountry': body!['addressCountry'], + 'swissTaxResidence': body!['swissTaxResidence'], + 'registrationDate': body!['registrationDate'], + 'walletAddress': body!['walletAddress'], + }, + }; + final messageHash = TypedDataUtil.hashMessage( + jsonData: jsonEncode(typedData), + version: TypedDataVersion.V4, + ); + final recoveredSigner = EthSigUtil.recoverSignature( + signature: body!['signature'] as String, + message: messageHash, + ); + + expect( + recoveredSigner.toLowerCase(), + _privKey.address.hexEip55.toLowerCase(), + ); + }, + ); + test( 'POSTs to /v1/realunit/register/complete with the ASCII-transliterated ' 'envelope, the EIP-712 signature, and the original KYC personal data', diff --git a/test/screens/kyc/steps/kyc_registration_page_test.dart b/test/screens/kyc/steps/kyc_registration_page_test.dart index ad9182df6..b4016efc3 100644 --- a/test/screens/kyc/steps/kyc_registration_page_test.dart +++ b/test/screens/kyc/steps/kyc_registration_page_test.dart @@ -38,6 +38,7 @@ import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registr import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; +import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; import '../../../helper/helper.dart'; @@ -826,6 +827,7 @@ void main() { Future showTaxStep( WidgetTester tester, { RealUnitUserDataDto dto = initialUserData, + String? nationalPhoneNumber, }) async { await tester.pumpApp( buildSubject(KycRegistrationView(initialUserData: dto)), @@ -833,6 +835,15 @@ void main() { // Let the seeded country lookups resolve before we jump to the tax page. await tester.pumpAndSettle(); + if (nationalPhoneNumber != null) { + final phoneNumberField = find.descendant( + of: find.byType(PhoneNumberField), + matching: find.byType(TextFormField), + ); + await tester.enterText(phoneNumberField.at(1), nationalPhoneNumber); + await tester.pump(); + } + final index = registrationStepCubit.state.index; (tester.widget(find.byType(PageView)) as PageView).controller?.jumpToPage(index); await tester.pumpAndSettle(); @@ -857,7 +868,7 @@ void main() { type: any(named: 'type'), firstName: any(named: 'firstName'), lastName: any(named: 'lastName'), - phoneNumber: any(named: 'phoneNumber'), + phoneNumber: captureAny(named: 'phoneNumber'), birthday: any(named: 'birthday'), nationality: any(named: 'nationality'), addressStreet: any(named: 'addressStreet'), @@ -912,6 +923,15 @@ void main() { await tester.pumpAndSettle(); } + testWidgets('submits the canonical Swiss phone number', (tester) async { + await showTaxStep(tester, nationalPhoneNumber: '0791234567'); + + await tapComplete(tester); + + final captured = captureSubmit(); + expect(captured[0], '+41791234567'); + }); + // S1 — Address CH, tax: CH only → swiss=true, countryAndTINs=null testWidgets( 'S1 CH only: locked Swiss address → swissTaxResidence true, null countryAndTINs', @@ -921,9 +941,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); - expect(captured[1], isTrue); - expect(captured[2], isNull); + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[2], isTrue); + expect(captured[3], isNull); }, ); @@ -939,9 +959,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); - expect(captured[1], isFalse); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[2], isFalse); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, '12 345 678 901'); @@ -960,9 +980,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); - expect(captured[1], isTrue); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[2], isTrue); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'FR'); expect(tins.single.tin, 'FR999'); @@ -983,9 +1003,9 @@ void main() { final captured = captureSubmit(); // DE (address, with TIN) + CH (additional) → swissTaxResidence true, // countryAndTINs only carries the non-CH entry. - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); - expect(captured[1], isTrue); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[2], isTrue); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, 'DE111'); @@ -1008,9 +1028,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); - expect(captured[1], isFalse); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[2], isFalse); + final tins = captured[3] as List; expect(tins, hasLength(3)); expect(tins[0].country, 'DE'); expect(tins[0].tin, 'DE111'); @@ -1068,9 +1088,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); - expect(captured[1], isTrue); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[2], isTrue); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, 'DE123'); diff --git a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart index 7cc90a373..c7824fa44 100644 --- a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart +++ b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart @@ -127,6 +127,9 @@ void main() { () => registrationService.completeRegistration(captureAny()), ).captured.single as Registration; + // Guard against changing the phone number while assembling the Registration. + // The mocked Page test and direct Service test would not observe that mutation. + expect(captured.phoneNumber, '+41791234567'); expect(captured.swissTaxResidence, isFalse); expect(captured.countryAndTINs, hasLength(1)); expect(captured.countryAndTINs!.single.country, 'DE'); 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');