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": "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": "Leave out the leading zero",
"registerPhoneNumberOnlyDigits": "Only numbers are allowed",
"registerPhoneNumberPrefixFormat": "Country code must be 1 to 3 digits",
"registerPhoneNumberPrefixInvalid": "Country code is required",
Expand Down
6 changes: 3 additions & 3 deletions docs/handbook/de/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7280,7 +7280,7 @@ <h2><span class="num">68</span>Daten bearbeiten — Formulare</h2>
/>
</div>
<div class="desc">
Das Formular <b>Telefonnummer ändern</b> im Ausgangszustand: eine Länderauswahl mit Vorwahl <b>+41</b> und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter <b>Speichern</b>. Hier trägt der Nutzer seine neue Telefonnummer ein.
Das Formular <b>Telefonnummer ändern</b> im Ausgangszustand: ein frei eingebbares Vorwahlfeld mit der Standardvorwahl <b>+41</b> und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter <b>Speichern</b>. Hier trägt der Nutzer seine neue Telefonnummer ein.
</div>
</div>
<div class="test" id="238-settings-edit-address-page-default">
Expand Down Expand Up @@ -8039,7 +8039,7 @@ <h2><span class="num">78</span>Telefonnummer-Eingabe</h2>
</div>
</summary>
<p class="spec-intro">
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.
</p>

<div class="tests cols-2">
Expand All @@ -8056,7 +8056,7 @@ <h2><span class="num">78</span>Telefonnummer-Eingabe</h2>
/>
</div>
<div class="desc">
Zeigt den <b>Ausgangszustand</b> des Telefonnummer-Felds: die Überschrift <b>Telefonnummer</b>, links das Vorwahl-Dropdown mit der Standardvorwahl <b>+41</b> (Schweiz, alternativ <b>+49</b> für Deutschland) und rechts das noch leere Rufnummernfeld mit dem Platzhalter <b>1231234567</b>. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft.
Zeigt den <b>Ausgangszustand</b> des Telefonnummer-Felds: die Überschrift <b>Telefonnummer</b>, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl <b>+41</b> (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter <b>1231234567</b>. 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.
</div>
</div>
</div>
Expand Down
14 changes: 9 additions & 5 deletions docs/screens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions test/goldens/widgets/form/phone_number_field_golden_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ import 'package:realunit_wallet/widgets/form/phone_number_field.dart';

import '../../../helper/helper.dart';

Widget _leadingZeroErrorField(GlobalKey<FormState> formKey, {Locale locale = const Locale('de')}) {
return wrapForGolden(
Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: formKey,
child: PhoneNumberField(
controller: ValueNotifier<String?>('+4100791234567'),
),
),
),
),
locale: locale,
);
}

Future<void> _validateThenSettle(WidgetTester tester, GlobalKey<FormState> formKey) async {
await tester.pumpAndSettle();
formKey.currentState!.validate();
await tester.pumpAndSettle();
}

void main() {
group('$PhoneNumberField', () {
goldenTest(
Expand All @@ -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<FormState>();
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<FormState>();
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')),
);
});
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -69,6 +70,7 @@ void main() {
Registration buildRegistration({
bool swissTaxResidence = true,
List<CountryAndTin>? countryAndTINs,
String phoneNumber = '+41 79 000 00 00',
}) => Registration(
type: RegistrationUserType.human,
email: 'AdA@ExAmPlE.COM',
Expand All @@ -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,
Expand Down Expand Up @@ -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<String, dynamic>? 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<String, dynamic>;
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',
Expand Down
Loading
Loading