Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 33 additions & 4 deletions lib/widgets/form/phone_number_field.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,6 +17,10 @@ class _PhoneNumberFieldState extends State<PhoneNumberField> {
// 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;

Expand All @@ -40,12 +45,29 @@ class _PhoneNumberFieldState extends State<PhoneNumberField> {
// 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;
}
}

Expand Down Expand Up @@ -115,7 +137,14 @@ class _PhoneNumberFieldState extends State<PhoneNumberField> {
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;
},
Expand Down
16 changes: 16 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
241 changes: 231 additions & 10 deletions test/widgets/form/phone_number_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
Loading