From 07bd48406244c6c7763585502645f053a85f9d2c Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 11:23:42 +0200 Subject: [PATCH 1/5] fix(registration): compose canonical E.164 before signing the registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registration phone number was composed exactly as typed, so the customary Swiss trunk-zero spelling (prefix `+41`, national part `079…`) was EIP-712 signed as `+410791234567`. The server normalises the field to E.164 before it verifies the signature, so verification ran over different bytes than the client signed and a valid submission was rejected. Compose the canonical value in the field instead: - Remove exactly one national trunk zero, and only for the dial codes whose numbering plan has one (`+41`, `+49`, `+43`). - Match on the fully composed value rather than per field, so the result is the same however the digits are split between the free-form dial-code field and the national part. - Apply the same composition to a pre-filled value before any user interaction. - Reject a residual `+410…`, `+490…` or `+430…` so it cannot advance through the form. Italian and Liechtenstein leading zeroes are significant and stay untouched; `+423` is deliberately absent from the trunk-zero list because Liechtenstein has no national trunk zero. --- lib/widgets/form/phone_number_field.dart | 32 +++- .../widgets/form/phone_number_field_test.dart | 159 ++++++++++++++++-- 2 files changed, 177 insertions(+), 14 deletions(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 023bd6d99..70b5e4b53 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -16,6 +16,9 @@ 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']; + // CH/DE/AT drop a leading national trunk 0. Italy's leading 0 is significant, + // and Liechtenstein has no national trunk 0. + static const _trunkZeroPrefixes = ['+41', '+49', '+43']; String? prefix; String? number; @@ -40,13 +43,27 @@ 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; + + // Seeded values must use the same trunk-0 composition as later edits. + 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) { + for (final countryPrefix in _trunkZeroPrefixes) { + final trunkPrefix = '${countryPrefix}0'; + if (value.startsWith(trunkPrefix)) { + return '$countryPrefix${value.substring(trunkPrefix.length)}'; + } } + return value; } @override @@ -115,7 +132,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).registerPhoneNumberInvalid; + } + // 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/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 656657bb4..868c2ad58 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,145 @@ 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('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 { + // Liechtenstein has no national trunk 0, so the entered leading zero + // must remain part of the national number. + 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('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('rejects multiple leading trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '00791234567'); + + expect(harness.controller.value, '+410791234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + 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, '+410791234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + 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'); From ea47870bf1b0190858d51ebd11bb155a47b96d41 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 11:30:50 +0200 Subject: [PATCH 2/5] fix(registration): name the leading zero in the phone field error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trunk-zero rejection returned `registerPhoneNumberInvalid`, which reads "Phone number is required" / "Telefonnummer ist erforderlich" — the same string the empty-field branch twelve lines above uses. Someone who typed `00791234567` therefore saw "is required" over a visibly filled field, with no hint that the leading zero was the cause. Add `registerPhoneNumberLeadingZero` and use it for that branch only; the empty-field branch keeps the old key. The two rejection tests now assert the new string. That also sharpens them: while both branches returned the same text, neither test could tell a trunk-zero rejection from an empty-field one. --- assets/languages/strings_de.arb | 1 + assets/languages/strings_en.arb | 1 + lib/widgets/form/phone_number_field.dart | 2 +- test/widgets/form/phone_number_field_test.dart | 4 ++-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 7ed95d199..3695ff81f 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": "Telefonnummer 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..0a0585d1a 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": "Enter the phone number without 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/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 70b5e4b53..d0ee76256 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -136,7 +136,7 @@ class _PhoneNumberFieldState extends State { if (_trunkZeroPrefixes.any( (countryPrefix) => canonical.startsWith('${countryPrefix}0'), )) { - return S.of(context).registerPhoneNumberInvalid; + return S.of(context).registerPhoneNumberLeadingZero; } // Apart from the explicit trunk-zero canonicality check above, // length is validated by the API (libphonenumber); the client diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 868c2ad58..5fe880b7e 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -220,7 +220,7 @@ void main() { expect(harness.controller.value, '+410791234567'); expect(isValid, isFalse); expect( - find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), findsOneWidget, ); }); @@ -256,7 +256,7 @@ void main() { expect(harness.controller.value, '+410791234567'); expect(isValid, isFalse); expect( - find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), findsOneWidget, ); }); From 2d6c6efd0f6417ce93ffe80fd49797f870636609 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 12:38:29 +0200 Subject: [PATCH 3/5] fix(registration): canonicalise the phone number from libphonenumber metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trunk-zero strip was driven by a hand-kept list of three dial codes, so every other numbering plan with a national trunk zero still composed a value the backend rejects. Measured with `libphonenumber-js` 1.12.25, the version the API resolves: `+330612345678` must be `+33612345678`, `+4407911123456` must be `+447911123456`, `+310612345678` must be `+31612345678`. Derive the canonical form from the same metadata the API uses instead, via `dlibphonenumber`. Measured over 490 cases built from libphonenumber's own example numbers across 245 countries — the canonical mobile number per country and the same number with a zero inserted after the dial code — the package agrees with `libphonenumber-js` on 490 of 490. `phone_numbers_parser` was measured too and differs in 5 of those cases, which is why it is not the one used here. `parse` throws while the national part is still being typed, so the raw value is passed through unchanged; the app must not gate on validity or length. `_trunkZeroPrefixes` stays, but only to report a surviving second leading zero in the field rather than letting it become a 400: `+4100791234567` is a fixed point of the canonicalisation, so the metadata alone does not catch it. --- lib/widgets/form/phone_number_field.dart | 19 ++++---- pubspec.lock | 16 +++++++ pubspec.yaml | 1 + .../widgets/form/phone_number_field_test.dart | 43 ++++++++++++++++++- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index d0ee76256..7fc19e36f 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -1,3 +1,4 @@ +import 'package:dlibphonenumber/dlibphonenumber.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:realunit_wallet/generated/i18n.dart'; @@ -16,8 +17,9 @@ 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']; - // CH/DE/AT drop a leading national trunk 0. Italy's leading 0 is significant, - // and Liechtenstein has no national trunk 0. + // 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; @@ -57,13 +59,14 @@ class _PhoneNumberFieldState extends State { } static String _canonicalize(String value) { - for (final countryPrefix in _trunkZeroPrefixes) { - final trunkPrefix = '${countryPrefix}0'; - if (value.startsWith(trunkPrefix)) { - return '$countryPrefix${value.substring(trunkPrefix.length)}'; - } + try { + final util = PhoneNumberUtil.instance; + return util.format(util.parse(value, null), PhoneNumberFormat.e164); + } catch (_) { + // `parse` throws NumberParseException for incomplete input while the user is + // typing; preserve the raw value and let the API decide validity on submit. + return value; } - return value; } @override 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/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 5fe880b7e..f6990a121 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -177,6 +177,36 @@ void main() { 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. @@ -212,12 +242,21 @@ void main() { 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, '+410791234567'); + expect(harness.controller.value, '+4100791234567'); expect(isValid, isFalse); expect( find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), @@ -253,7 +292,7 @@ void main() { final isValid = harness.formKey.currentState!.validate(); await tester.pump(); - expect(harness.controller.value, '+410791234567'); + expect(harness.controller.value, '+4100791234567'); expect(isValid, isFalse); expect( find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), From 26a2b9ba54566ac7d3e8ae992d4ce6cd2343625a Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 13:07:42 +0200 Subject: [PATCH 4/5] fix(registration): catch only the parse failure, not every exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_canonicalize` caught every exception and returned the raw value. The comment named the one case that is expected — incomplete input while the national part is still being typed — but the code also swallowed a genuine defect in the metadata library, which would then reach the API as a 400 instead of failing visibly. Narrow it to `NumberParseException`, the type measured for `+41`, `+417`, `+410`, `+49` and `+423`. Anything else now propagates. The incomplete-input test stays green, which is what pins that this is the type actually thrown. --- lib/widgets/form/phone_number_field.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 7fc19e36f..d1aa89055 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -62,9 +62,10 @@ class _PhoneNumberFieldState extends State { try { final util = PhoneNumberUtil.instance; return util.format(util.parse(value, null), PhoneNumberFormat.e164); - } catch (_) { + } 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; } } From 1ad452debb06a3e7b4cd1e875566da597e35cfb4 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 2 Sep 2026 17:46:09 +0200 Subject: [PATCH 5/5] fix(registration): pin every trunk-zero prefix and correct the LI claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double-zero guard listed +41, +49 and +43, but only +41 was covered by a test: removing the other two entries left the suite green. Adds a case for each. The Liechtenstein test claimed LI has no national trunk 0. It has one — the zero is stripped as soon as the result has a possible length. The test only passed because its number was too short for that, so the comment described a rule that does not exist. Corrects it and pins the actual behaviour. Also moves the dlibphonenumber import behind the flutter imports, per the import order in CONTRIBUTING.md. --- lib/widgets/form/phone_number_field.dart | 5 +- .../widgets/form/phone_number_field_test.dart | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index d1aa89055..02ce210e3 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -1,6 +1,6 @@ -import 'package:dlibphonenumber/dlibphonenumber.dart'; 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'; @@ -46,7 +46,8 @@ class _PhoneNumberFieldState extends State { // so the validator still blocks submit until it is re-entered. prefix ??= prefixes.first; - // Seeded values must use the same trunk-0 composition as later edits. + // Canonicalization here only applies when the loop above split the seed. + // An unrecognized dial code leaves number null, so updatePhoneNumber() writes nothing. updatePhoneNumber(); } diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index f6990a121..6b4f968cd 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -220,8 +220,8 @@ void main() { }); testWidgets('keeps a leading Liechtenstein zero in the stored number', (tester) async { - // Liechtenstein has no national trunk 0, so the entered leading zero - // must remain part of the national number. + // 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'); @@ -231,6 +231,19 @@ void main() { 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 { @@ -264,6 +277,36 @@ void main() { ); }); + 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');