diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index e797fa309..797ed36fd 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -92,8 +92,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "279" ]; then - echo "expected 279 screenshots, got $count" >&2 + if [ "$count" != "278" ]; then + echo "expected 278 screenshots, got $count" >&2 exit 1 fi @@ -173,7 +173,7 @@ jobs: exit 1 fi - # Screenshots dir must contain all 279 PNGs assembled from Goldens. + # Screenshots dir must contain all 278 PNGs assembled from Goldens. # Hit one of them through the auth gate to verify wiring end-to-end. # Mix of the original 01-61 range and the 62-269 batch (every Golden # baseline) so a regression in either half surfaces here. diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6587b7800..ffb42e80f 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -286,6 +286,8 @@ "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", + "registerPhoneNumberPrefixFormat": "Vorwahl muss aus 1 bis 3 Ziffern bestehen", + "registerPhoneNumberPrefixInvalid": "Vorwahl ist erforderlich", "registrationFailed": "Registrierung fehlgeschlagen:\n${message}", "registrationForwardingFailed": "Registrierung angenommen, aber die Weiterleitung an die Gesellschaft ist verzögert. Wir versuchen es automatisch erneut.", "registrationRejected": "Der Server hat Ihre Registrierung abgelehnt:\n${reason}\nIhre Daten wurden nicht gespeichert — bitte prüfen Sie Ihre Angaben und senden Sie erneut ab.", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 24998fbfb..1805cc565 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -286,6 +286,8 @@ "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", + "registerPhoneNumberPrefixFormat": "Country code must be 1 to 3 digits", + "registerPhoneNumberPrefixInvalid": "Country code is required", "registrationFailed": "Registration failed:\n${message}", "registrationForwardingFailed": "Registration accepted, but forwarding to the company is delayed. We will retry automatically.", "registrationRejected": "The server rejected your registration:\n${reason}\nYour data has not been saved — please check your entries and submit again.", diff --git a/docs/handbook/README.md b/docs/handbook/README.md index e929bad84..4a8807a7a 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 279 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 278 Handbook-Screenshots sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index f8e42e4c8..1ac711256 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -5588,22 +5588,6 @@

53KYC — Registrierung

Das geöffnete Kontotyp-Auswahlmenü im persönlichen Schritt. Das Overlay zeigt die auswählbaren Kontotypen. -
-
- - - screens/kyc/goldens/macos/kyc_registration_personal_step_phone_prefix_open.png -
-
- Registrierung — Vorwahl-Dropdown offen -
-
- Das geöffnete Auswahlmenü für die Telefon-Ländervorwahl (z. B. +41 / +49). Der Nutzer wählt hier die Vorwahl vor der Eingabe der Telefonnummer. -
-
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, '+'); + }); }); }