165-kyc-registration-personal-step-validation-error
diff --git a/docs/screens.md b/docs/screens.md
index aceea585c..9784edae2 100644
--- a/docs/screens.md
+++ b/docs/screens.md
@@ -16,7 +16,7 @@ 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 279 Golden baselines** — every screen **plus every tested
+ covers **all 278 Golden baselines** — every screen **plus every tested
state variant** (Default / Loading / Error / Snackbar / Dropdown /
Validation / Confirm / Success / Failure …), including the areas that were
previously absent: Support (email capture, tickets, chat), Settings
@@ -136,7 +136,7 @@ one shared form-widget baseline (`PhoneNumberField`), which is not a screen.
`243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route
(`/settings/security/changePin`) via a second constructor; that reuse has no
separate Golden and is not given its own row.
-- **Handbook numbering.** Each of the 279 handbook slots is a Visual-Regression
+- **Handbook numbering.** Each of the 278 handbook slots is a Visual-Regression
Golden under `test/goldens/`, mapped to its handbook position by
`scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow
(`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the
diff --git a/lib/widgets/form/labeled_text_field.dart b/lib/widgets/form/labeled_text_field.dart
index 79a38bbe0..906baefc6 100644
--- a/lib/widgets/form/labeled_text_field.dart
+++ b/lib/widgets/form/labeled_text_field.dart
@@ -7,6 +7,7 @@ class LabeledTextField extends StatelessWidget {
final String? initialValue;
final TextEditingController? controller;
final String? hintText;
+ final String? prefixText;
final TextInputType? keyboardType;
final void Function(String)? onChanged;
final String? Function(String?)? validator;
@@ -21,6 +22,7 @@ class LabeledTextField extends StatelessWidget {
this.initialValue,
this.controller,
this.hintText,
+ this.prefixText,
this.keyboardType,
this.onChanged,
this.validator,
@@ -57,6 +59,7 @@ class LabeledTextField extends StatelessWidget {
textCapitalization: textCapitalization,
decoration: InputDecoration(
hintText: hintText,
+ prefixText: prefixText,
enabledBorder: const OutlineInputBorder(
borderRadius: .all(.circular(8.0)),
borderSide: BorderSide(color: RealUnitColors.neutral300),
diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart
index a2684db8d..023bd6d99 100644
--- a/lib/widgets/form/phone_number_field.dart
+++ b/lib/widgets/form/phone_number_field.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:realunit_wallet/generated/i18n.dart';
-import 'package:realunit_wallet/widgets/form/dropdown_field.dart';
import 'package:realunit_wallet/widgets/form/labeled_text_field.dart';
class PhoneNumberField extends StatefulWidget {
@@ -13,7 +13,9 @@ class PhoneNumberField extends StatefulWidget {
}
class _PhoneNumberFieldState extends State
{
- final prefixes = ['+41', '+49'];
+ // 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'];
String? prefix;
String? number;
@@ -21,7 +23,10 @@ class _PhoneNumberFieldState extends State {
void initState() {
super.initState();
final value = widget.controller.value;
- for (final p in prefixes) {
+ // Longest first. Keep this whenever the list mixes lengths: a shorter listed
+ // code that is a true prefix of a longer one would steal the match.
+ final knownPrefixes = List.of(prefixes)..sort((a, b) => b.length.compareTo(a.length));
+ for (final p in knownPrefixes) {
if (value != null && value.startsWith(p)) {
prefix = p;
number = value.substring(p.length);
@@ -29,10 +34,10 @@ class _PhoneNumberFieldState extends State {
}
}
- // A seeded value this field cannot decompose (empty, or a dial code it does not offer) must not
- // leave `prefix` null: the dropdown carries no validator, so `Form.validate()` would pass while
- // `updatePhoneNumber()` silently refused to write, and the stale value would be submitted
- // instead of what the user typed. Fall back to the first prefix; the number field starts empty,
+ // A seeded value this field cannot decompose (empty, or a dial code it does not
+ // recognize) must not leave `prefix` null: `updatePhoneNumber()` silently refuses
+ // to write when prefix is null, and the stale value would be submitted instead of
+ // 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;
}
@@ -66,16 +71,29 @@ class _PhoneNumberFieldState extends State {
children: [
Expanded(
flex: 3,
- child: DropdownField(
- initialValue: prefix,
- items: prefixes
- .map((d) => DropdownMenuItem(value: d, child: Text(d.toString())))
- .toList(),
+ child: LabeledTextField(
+ initialValue: prefix?.substring(1),
+ prefixText: '+',
+ keyboardType: .phone,
+ inputFormatters: [
+ FilteringTextInputFormatter.digitsOnly,
+ LengthLimitingTextInputFormatter(3),
+ ],
onChanged: (v) {
- if (v != null) {
- prefix = v;
- updatePhoneNumber();
+ prefix = '+$v';
+ updatePhoneNumber();
+ },
+ hideErrorText: false,
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return S.of(context).registerPhoneNumberPrefixInvalid;
}
+ if (!RegExp(r'^[0-9]{1,3}$').hasMatch(value)) {
+ return S.of(context).registerPhoneNumberPrefixFormat;
+ }
+ // Existence of the dial code is validated by the API (libphonenumber); the
+ // client must not gate on it — see CONTRIBUTING "the API decides".
+ return null;
},
),
),
diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh
index 33e716baa..5476d1110 100755
--- a/scripts/assemble-handbook-screenshots.sh
+++ b/scripts/assemble-handbook-screenshots.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
-# Assemble the 279 handbook screenshots from the visual-regression Golden
+# Assemble the 278 handbook screenshots from the visual-regression Golden
# baselines. The flat `NN-name.png` output layout matches what
# docs/handbook/de/index.html links to (`
`
# — the relative path resolves to `docs/handbook/screenshots/NN-name.png`).
@@ -211,7 +211,6 @@ MAPPING=(
"161-kyc-registration-page-default=screens/kyc/goldens/macos/kyc_registration_page_default.png"
"162-kyc-registration-page-prefilled=screens/kyc/goldens/macos/kyc_registration_page_prefilled.png"
"163-kyc-registration-personal-step-account-type-open=screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png"
- "164-kyc-registration-personal-step-phone-prefix-open=screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png"
"165-kyc-registration-personal-step-validation-error=screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png"
"166-kyc-registration-page-address-step=screens/kyc/goldens/macos/kyc_registration_page_address_step.png"
"167-kyc-registration-address-step-validation-error=screens/kyc/goldens/macos/kyc_registration_address_step_validation_error.png"
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png
index 8c72f9ff2..5ca09a388 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png
index b765ed6c1..2c0628823 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_default.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png
index 0efa30889..8aca29f55 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_forwarding_failed_snackbar.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png
index 3c2272d60..eb52acc20 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_prefilled.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png
index 2ffe0203c..18dc06a34 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_failure_snackbar.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png
index 7d98dff00..ea3198326 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_page_submit_rejected_snackbar.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png
index 4cf953c4a..05e529c69 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_account_type_open.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png
index 49683a12c..dbea943d6 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_default.png differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png
deleted file mode 100644
index aeefe0118..000000000
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png and /dev/null differ
diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png
index 7329b397a..9ddf97dc1 100644
Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_personal_step_validation_error.png differ
diff --git a/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart
index 69c380b6d..ae411ca5a 100644
--- a/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart
+++ b/test/goldens/screens/kyc/kyc_registration_personal_step_states_golden_test.dart
@@ -93,24 +93,5 @@ void main() {
},
builder: buildSubject,
);
-
- goldenTest(
- 'phone-prefix dropdown open — the +41 / +49 overlay menu',
- fileName: 'kyc_registration_personal_step_phone_prefix_open',
- constraints: phoneConstraints,
- pumpBeforeTest: (tester) async {
- await tester.pumpAndSettle();
- // The prefix dropdown renders its selected value '+41'; the birthday
- // String dropdowns show day/month/year hints instead, so this is unique.
- final field = find.widgetWithText(
- DropdownButtonFormField,
- '+41',
- );
- await tester.ensureVisible(field);
- await tester.tap(field);
- await tester.pumpAndSettle();
- },
- builder: buildSubject,
- );
});
}
diff --git a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png
index 5ceecb549..36b0bb97d 100644
Binary files a/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png and b/test/goldens/screens/settings_user_data/goldens/macos/settings_edit_phone_number_page_default.png differ
diff --git a/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png b/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png
index 400c89f80..afa9d27b4 100644
Binary files a/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png and b/test/goldens/widgets/form/goldens/macos/phone_number_field_default.png differ
diff --git a/test/screens/kyc/steps/kyc_personal_data_page_test.dart b/test/screens/kyc/steps/kyc_personal_data_page_test.dart
index 720475d93..9e75c1844 100644
--- a/test/screens/kyc/steps/kyc_personal_data_page_test.dart
+++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart
@@ -149,9 +149,9 @@ void main() {
testWidgets('renders every field the personal-data step submits', (tester) async {
await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())));
- // six placed directly (first/last name, street, house number, postcode, city) plus the one
- // PhoneNumberField nests for the number input
- expect(find.byType(LabeledTextField), findsNWidgets(7));
+ // six placed directly (first/last name, street, house number, postcode, city) plus the two
+ // PhoneNumberField nests for prefix and number
+ expect(find.byType(LabeledTextField), findsNWidgets(8));
expect(find.byType(PhoneNumberField), findsOne);
expect(find.byType(CountryField), findsOne);
expect(find.byType(FilledButton), findsOne);
@@ -180,7 +180,7 @@ void main() {
await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: dto)));
await tester.pumpAndSettle();
- expect(find.text('+41'), findsOne);
+ expect(find.text('41'), findsOne);
});
// The country lookup is fire-and-forget; without a catch a failing GET escapes as an uncaught
diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart
index f888c0965..656657bb4 100644
--- a/test/widgets/form/phone_number_field_test.dart
+++ b/test/widgets/form/phone_number_field_test.dart
@@ -35,12 +35,16 @@ Future<_PhoneFieldHarness> _pumpPhoneField(
return _PhoneFieldHarness(formKey: formKey, controller: controller);
}
+Finder _prefixField() => find.byType(TextFormField).first;
+
+Finder _numberField() => find.byType(TextFormField).at(1);
+
Future _enterAndValidate(
WidgetTester tester,
_PhoneFieldHarness harness,
String nationalNumber,
) async {
- await tester.enterText(find.byType(TextFormField), nationalNumber);
+ await tester.enterText(_numberField(), nationalNumber);
final isValid = harness.formKey.currentState!.validate();
await tester.pump();
@@ -68,6 +72,20 @@ void main() {
expect(harness.controller.value, isNull);
});
+ testWidgets('shows the required error for an empty prefix', (tester) async {
+ final harness = await _pumpPhoneField(tester);
+
+ await tester.enterText(_prefixField(), '');
+ final isValid = harness.formKey.currentState!.validate();
+ await tester.pump();
+
+ expect(isValid, isFalse);
+ expect(
+ find.text(_phoneError(tester, (s) => s.registerPhoneNumberPrefixInvalid)),
+ findsOneWidget,
+ );
+ });
+
testWidgets('shows the digits-only error for non-digit input', (tester) async {
final harness = await _pumpPhoneField(tester);
@@ -117,18 +135,80 @@ void main() {
expect(isValid, isTrue);
});
+ testWidgets('accepts a non-CH/DE prefix and composes the stored number', (tester) async {
+ final harness = await _pumpPhoneField(tester);
+
+ await tester.enterText(_prefixField(), '43');
+ final isValid = await _enterAndValidate(tester, harness, '12345');
+
+ expect(harness.controller.value, '+4312345');
+ expect(isValid, isTrue);
+ });
+
testWidgets('switching the country prefix recomposes the stored number', (tester) async {
final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+41791234567');
- // Drive the prefix dropdown's onChanged directly (deterministic, no
- // overlay menu): the national part is preserved and re-prefixed.
- final dropdown = tester.widget>(
- find.byType(DropdownButtonFormField),
- );
- dropdown.onChanged!('+49');
- await tester.pumpAndSettle();
+ await tester.enterText(_prefixField(), '49');
+ await tester.pump();
expect(harness.controller.value, '+49791234567');
});
+
+ testWidgets('does not accept more than 3 prefix digits', (tester) async {
+ final harness = await _pumpPhoneField(tester);
+
+ // LengthLimitingTextInputFormatter keeps the old value when the field is
+ // already at maxLength and the incoming edit is longer (collapsed
+ // selection). Clear first so '1234' is truncated to '123' rather than
+ // rejected against a 3-digit seed.
+ await tester.enterText(_prefixField(), '');
+ await tester.enterText(_prefixField(), '1234');
+ await tester.enterText(_numberField(), '791234567');
+ await tester.pump();
+
+ final prefixEditable = tester.widget(
+ find.descendant(of: _prefixField(), matching: find.byType(EditableText)),
+ );
+ expect(prefixEditable.controller.text, '123');
+ expect(harness.controller.value, '+123791234567');
+ });
+
+ testWidgets('accepts a 3-digit prefix and composes the stored number', (tester) async {
+ final harness = await _pumpPhoneField(tester);
+
+ await tester.enterText(_prefixField(), '423');
+ final isValid = await _enterAndValidate(tester, harness, '6641234567');
+
+ expect(harness.controller.value, '+4236641234567');
+ expect(isValid, isTrue);
+ });
+
+ testWidgets('decomposes a seeded +423 number without letting +43 eat it', (tester) async {
+ await _pumpPhoneField(tester, initialPhoneNumber: '+4236641234567');
+
+ final prefixEditable = tester.widget(
+ find.descendant(of: _prefixField(), matching: find.byType(EditableText)),
+ );
+ final numberEditable = tester.widget(
+ find.descendant(of: _numberField(), matching: find.byType(EditableText)),
+ );
+ expect(prefixEditable.controller.text, '423');
+ expect(numberEditable.controller.text, '6641234567');
+ });
+
+ testWidgets('keeps + out of the prefix field text and on prefixText', (tester) async {
+ final harness = await _pumpPhoneField(tester);
+
+ await tester.enterText(_prefixField(), '41');
+ await tester.enterText(_numberField(), '791234567');
+ await tester.pump();
+
+ expect(harness.controller.value, '+41791234567');
+ expect(find.text('+41'), findsNothing);
+ final prefixTextField = tester.widget(
+ find.descendant(of: _prefixField(), matching: find.byType(TextField)),
+ );
+ expect(prefixTextField.decoration?.prefixText, '+');
+ });
});
}