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');