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