From 87827a8f9add87bb045433a8a65443100454616a Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 00:05:05 -0300 Subject: [PATCH 01/12] feat(kyc): add the PersonalData step page Registration satisfies PersonalData without the user ever seeing it, so the app never had a page for it. The step re-opens when identification rejects the submitted data as not matching the document - the API fails the completed step and opens a fresh one specifically so the account can correct it. The app could not render that step, so instead of a correction form the user got the unsupported-step failure screen and onboarding dead-ended with no way forward. The page submits the same KycPersonalData shape the endpoint expects, via the generic setData PUT already used by the nationality and settings address/name flows. houseNumber is omitted rather than sent empty because the API joins it onto street. Two existing tests encoded the old behaviour and were updated rather than worked around: one used personalData as its example of an unmapped step name, and the enum guard pinned ten variants. --- assets/languages/strings_de.arb | 3 + assets/languages/strings_en.arb | 3 + lib/screens/kyc/cubits/kyc/kyc_cubit.dart | 1 + lib/screens/kyc/cubits/kyc/kyc_state.dart | 1 + lib/screens/kyc/kyc_page_manager.dart | 2 + .../kyc_personal_data_cubit.dart | 50 ++++ .../kyc_personal_data_state.dart | 29 +++ .../personal_data/kyc_personal_data_page.dart | 238 ++++++++++++++++++ .../kyc/cubits/kyc/kyc_cubit_test.dart | 41 ++- .../kyc_personal_data_cubit_test.dart | 121 +++++++++ .../kyc_bitbox_create_wallet_states_test.dart | 3 +- 11 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart create mode 100644 lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart create mode 100644 lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart create mode 100644 test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index d27b1cae..c7b73d51 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -130,6 +130,9 @@ "kycMergeProcessingTitle": "Konten werden zusammengeführt", "kycPending": "Daten werden geprüft", "kycPendingDescription": "Ihr folgender Schritt ist gerade noch unter Prüfung: ${step}. Bitte haben Sie noch ein wenig Geduld und schauen Sie zu einem späteren Zeitpunkt nochmal rein.", + "kycPersonalDataDescription": "Bitte überprüfen Sie Ihre Angaben und korrigieren Sie alles, was nicht mit Ihrem Ausweisdokument übereinstimmt.", + "kycPersonalDataFailed": "Persönliche Daten konnten nicht gespeichert werden:\n${message}", + "kycPersonalDataTitle": "Persönliche Daten", "kycSignatureUnsupportedDescription": "Dieses Feature erfordert eine EIP-712-Signatur. Im Debug-Modus (Adresse + Signatur) ist dies technisch nicht möglich. Bitte verwenden Sie eine Software-Wallet oder BitBox, um RealUnit zu nutzen.", "kycSignatureUnsupportedTitle": "Signatur nicht verfügbar", "kycUnsupportedStepDescription": "Der aktuelle KYC-Schritt (${step}) kann in dieser App nicht abgeschlossen werden. Bitte kontaktieren Sie den Support.", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 913f4061..6df500e8 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -130,6 +130,9 @@ "kycMergeProcessingTitle": "Merging your accounts", "kycPending": "Data is being verified", "kycPendingDescription": "Your next step is currently being reviewed: ${step}. Please be patient and check back later.", + "kycPersonalDataDescription": "Please check your details and correct anything that does not match your identity document.", + "kycPersonalDataFailed": "Could not save your personal data:\n${message}", + "kycPersonalDataTitle": "Personal data", "kycSignatureUnsupportedDescription": "This feature requires an EIP-712 signature. The Debug mode (address + signature) cannot produce one. Please use a Software Wallet or a BitBox to use RealUnit.", "kycSignatureUnsupportedTitle": "Signature not available", "kycUnsupportedStepDescription": "The current KYC step (${step}) cannot be completed in this app. Please contact support.", diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart index 89e3c50e..e738e74f 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart @@ -344,6 +344,7 @@ class KycCubit extends Cubit { KycStep? _mapStepName(KycStepName name) => switch (name) { KycStepName.contactData => KycStep.registration, + KycStepName.personalData => KycStep.personalData, KycStepName.nationalityData => KycStep.nationality, KycStepName.ident => KycStep.ident, KycStepName.financialData => KycStep.financialData, diff --git a/lib/screens/kyc/cubits/kyc/kyc_state.dart b/lib/screens/kyc/cubits/kyc/kyc_state.dart index b5d0d47b..a85bddf2 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_state.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_state.dart @@ -4,6 +4,7 @@ enum KycStep { email, confirmEmail, registration, + personalData, linkWallet, legalDisclaimer, nationality, diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index c5ae9013..84c0672f 100644 --- a/lib/screens/kyc/kyc_page_manager.dart +++ b/lib/screens/kyc/kyc_page_manager.dart @@ -14,6 +14,7 @@ import 'package:realunit_wallet/screens/kyc/steps/financial_data/kyc_financial_d import 'package:realunit_wallet/screens/kyc/steps/ident/kyc_ident_page.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart'; import 'package:realunit_wallet/screens/kyc/steps/nationality/kyc_nationality_page.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; import 'package:realunit_wallet/screens/kyc/steps/registration/kyc_registration_page.dart'; import 'package:realunit_wallet/screens/kyc/steps/signature_unsupported/kyc_signature_unsupported_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_account_merge_page.dart'; @@ -75,6 +76,7 @@ class KycViewManager extends StatelessWidget { }, ), KycStep.registration => KycRegistrationPage(initialUserData: realUnitUserData), + KycStep.personalData => KycPersonalDataPage(url: urlOrToken ?? ''), KycStep.linkWallet => KycLinkWalletPage(userData: realUnitUserData), KycStep.nationality => KycNationalityPage(url: urlOrToken ?? ''), KycStep.twoFa => const Kyc2FaPage(), diff --git a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart new file mode 100644 index 00000000..8b0a3670 --- /dev/null +++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; + +part 'kyc_personal_data_state.dart'; + +class KycPersonalDataCubit extends Cubit { + final DfxKycService _kycService; + + KycPersonalDataCubit(DfxKycService kycService) + : _kycService = kycService, + super(const KycPersonalDataInitial()); + + Future submit({ + required String url, + required String firstName, + required String lastName, + required String phone, + required String street, + required String houseNumber, + required String zip, + required String city, + required Country country, + }) async { + try { + emit(const KycPersonalDataLoading()); + await _kycService.setData(url, { + // The step only ever opens for accounts the app registered itself, and the type dropdown + // offers `human` alone, so the account type is not re-asked here. + 'accountType': 'Personal', + 'firstName': firstName, + 'lastName': lastName, + 'phone': phone, + 'address': { + 'street': street, + // Omitted rather than sent empty: the API treats houseNumber as optional and joins it onto + // street, so a blank one would produce a trailing space in the stored address. + if (houseNumber.isNotEmpty) 'houseNumber': houseNumber, + 'zip': zip, + 'city': city, + 'country': {'id': country.id}, + }, + }); + emit(const KycPersonalDataSuccess()); + } catch (e) { + emit(KycPersonalDataFailure(e.toString())); + } + } +} diff --git a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart new file mode 100644 index 00000000..41e2fb90 --- /dev/null +++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_state.dart @@ -0,0 +1,29 @@ +part of 'kyc_personal_data_cubit.dart'; + +abstract class KycPersonalDataState extends Equatable { + const KycPersonalDataState(); + + @override + List get props => []; +} + +class KycPersonalDataInitial extends KycPersonalDataState { + const KycPersonalDataInitial(); +} + +class KycPersonalDataLoading extends KycPersonalDataState { + const KycPersonalDataLoading(); +} + +class KycPersonalDataSuccess extends KycPersonalDataState { + const KycPersonalDataSuccess(); +} + +class KycPersonalDataFailure extends KycPersonalDataState { + final String message; + + const KycPersonalDataFailure(this.message); + + @override + List get props => [message]; +} diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart new file mode 100644 index 00000000..8979827b --- /dev/null +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/form/country_field.dart'; +import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; +import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; + +/// The PersonalData KYC step. +/// +/// Registration normally satisfies this step without the user ever seeing it. It surfaces when the +/// step was re-opened deliberately — identification rejected the submitted data as not matching the +/// document — and the account has to correct it before identification can be retried. Until this +/// page existed the API asked for a step the app could not render, and onboarding dead-ended. +class KycPersonalDataPage extends StatelessWidget { + final String url; + + const KycPersonalDataPage({super.key, required this.url}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => KycPersonalDataCubit(getIt()), + child: KycPersonalDataView(url: url), + ); + } +} + +class KycPersonalDataView extends StatefulWidget { + final String url; + + const KycPersonalDataView({super.key, required this.url}); + + @override + State createState() => _KycPersonalDataViewState(); +} + +class _KycPersonalDataViewState extends State { + final _formKey = GlobalKey(); + final firstNameCtrl = TextEditingController(); + final lastNameCtrl = TextEditingController(); + final phoneCtrl = ValueNotifier(null); + final streetCtrl = TextEditingController(); + final houseNumberCtrl = TextEditingController(); + final zipCtrl = TextEditingController(); + final cityCtrl = TextEditingController(); + final countryCtrl = ValueNotifier(null); + + String? _required(String? value) { + if (value == null || value.isEmpty) return ''; + if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + return null; + } + + void _submit() { + FocusManager.instance.primaryFocus?.unfocus(); + if (!(_formKey.currentState?.validate() ?? false)) return; + + // The country field and the phone field validate themselves, but neither writes into the Form, + // so an untouched one leaves its notifier null and would submit an incomplete body. + final country = countryCtrl.value; + final phone = phoneCtrl.value; + if (country == null || phone == null || phone.isEmpty) return; + + context.read().submit( + url: widget.url, + firstName: firstNameCtrl.text, + lastName: lastNameCtrl.text, + phone: phone, + street: streetCtrl.text, + houseNumber: houseNumberCtrl.text, + zip: zipCtrl.text, + city: cityCtrl.text, + country: country, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).kycPersonalDataTitle)), + body: BlocListener( + listener: (context, state) { + if (state is KycPersonalDataSuccess) { + // The API decides what comes next; re-reading it is what moves the flow on. + context.read().checkKyc(); + } + if (state is KycPersonalDataFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).kycPersonalDataFailed(state.message)), + backgroundColor: RealUnitColors.status.red600, + ), + ); + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.opaque, + child: Form( + key: _formKey, + child: Column( + spacing: 16, + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text(S.of(context).kycPersonalDataDescription), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + Expanded( + child: LabeledTextField( + label: S.of(context).firstName, + hintText: 'Max', + controller: firstNameCtrl, + textCapitalization: TextCapitalization.words, + validator: _required, + ), + ), + Expanded( + child: LabeledTextField( + label: S.of(context).lastName, + hintText: 'Mustermann', + controller: lastNameCtrl, + textCapitalization: TextCapitalization.words, + validator: _required, + ), + ), + ], + ), + PhoneNumberField(controller: phoneCtrl), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + Expanded( + flex: 2, + child: LabeledTextField( + hintText: S.of(context).streetHint, + controller: streetCtrl, + label: S.of(context).street, + keyboardType: TextInputType.streetAddress, + textCapitalization: TextCapitalization.words, + validator: _required, + ), + ), + Expanded( + child: LabeledTextField( + hintText: '13', + controller: houseNumberCtrl, + label: S.of(context).number, + keyboardType: TextInputType.streetAddress, + validator: _required, + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + Expanded( + flex: 2, + child: LabeledTextField( + hintText: '8000', + controller: zipCtrl, + label: S.of(context).postcodeAbr, + // Alphanumeric in many residence countries (NL "1011 AB", UK "EC1A 1BB"), + // so not a number-only keyboard — mirrors the registration address step. + keyboardType: TextInputType.text, + validator: _required, + ), + ), + Expanded( + flex: 3, + child: LabeledTextField( + hintText: S.of(context).cityHint, + controller: cityCtrl, + label: S.of(context).city, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.words, + validator: _required, + ), + ), + ], + ), + CountryField( + label: S.of(context).country, + purpose: CountryFieldPurpose.residence, + onChanged: (country) => countryCtrl.value = country, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: BlocBuilder( + builder: (context, state) { + return AppFilledButton( + state: state is KycPersonalDataLoading ? .loading : .idle, + onPressed: _submit, + label: S.of(context).next, + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + @override + void dispose() { + firstNameCtrl.dispose(); + lastNameCtrl.dispose(); + phoneCtrl.dispose(); + streetCtrl.dispose(); + houseNumberCtrl.dispose(); + zipCtrl.dispose(); + cityCtrl.dispose(); + countryCtrl.dispose(); + super.dispose(); + } +} diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart index 27fcb1d9..804e99e6 100644 --- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart +++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart @@ -809,9 +809,9 @@ void main() { ); // PendingReview + a required step the app cannot render (e.g. - // additionalDocuments, residencePermit, statutes, personalData — all - // absent from `_mapStepName`). Must surface an explicit failure with - // the step name, never `KycCompleted`. + // additionalDocuments, residencePermit, statutes — all absent from + // `_mapStepName`). Must surface an explicit failure with the step name, + // never `KycCompleted`. blocTest( 'emits KycUnsupportedStepFailure(step) when PendingReview required step is unmapped', setUp: () { @@ -838,6 +838,35 @@ void main() { ], ); + // Registration normally satisfies PersonalData without the user seeing it. It re-opens when + // identification rejects the submitted data, and until the page existed that landed the user on + // the unsupported-step failure screen with no way to correct anything. + blocTest( + 'routes PersonalData to its own step instead of the unsupported-step failure', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level20, + processStatus: KycProcessStatus.pendingReview, + steps: [ + _step( + KycStepName.personalData, + status: KycStepStatus.inProgress, + isRequired: true, + ), + ], + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycPending(KycStep.personalData), + ], + ); + blocTest( 'emits KycPending(dfxApproval) when dfxApproval is the only required step in PendingReview', setUp: () { @@ -903,7 +932,9 @@ void main() { (_) async => _session( level: KycLevel.level20, steps: const [], - currentStep: _currentStep(KycStepName.personalData), + // personalData used to stand in for "unmapped" here; it has its own page now, so this + // needs a name that is still absent from `_mapStepName`. + currentStep: _currentStep(KycStepName.statutes), ), ); }, @@ -911,7 +942,7 @@ void main() { act: (cubit) => cubit.checkKyc(), expect: () => [ const KycLoading(), - const KycUnsupportedStepFailure(KycStepName.personalData), + const KycUnsupportedStepFailure(KycStepName.statutes), ], ); diff --git a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart new file mode 100644 index 00000000..25c1942b --- /dev/null +++ b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart @@ -0,0 +1,121 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; + +class _MockKycService extends Mock implements DfxKycService {} + +const _switzerland = Country( + id: 41, + symbol: 'CH', + name: 'Switzerland', + kycAllowed: true, +); + +void main() { + late _MockKycService service; + + setUpAll(() { + registerFallbackValue({}); + }); + + setUp(() { + service = _MockKycService(); + }); + + KycPersonalDataCubit build() => KycPersonalDataCubit(service); + + Future submit(KycPersonalDataCubit c, {String houseNumber = '13'}) => c.submit( + url: 'https://kyc/data/personal/1', + firstName: 'Erika', + lastName: 'Mueller', + phone: '+41790000000', + street: 'Bahnhofstrasse', + houseNumber: houseNumber, + zip: '8001', + city: 'Zurich', + country: _switzerland, + ); + + group('initial state', () { + test('emits $KycPersonalDataInitial', () { + expect(build().state, isA()); + }); + }); + + group('submit', () { + blocTest( + 'success: forwards the KycPersonalData body to setData; Loading → Success', + setUp: () => when(() => service.setData(any(), any())).thenAnswer((_) async {}), + build: build, + act: submit, + expect: () => const [KycPersonalDataLoading(), KycPersonalDataSuccess()], + verify: (_) => verify( + () => service.setData('https://kyc/data/personal/1', { + 'accountType': 'Personal', + 'firstName': 'Erika', + 'lastName': 'Mueller', + 'phone': '+41790000000', + 'address': { + 'street': 'Bahnhofstrasse', + 'houseNumber': '13', + 'zip': '8001', + 'city': 'Zurich', + 'country': {'id': 41}, + }, + }), + ).called(1), + ); + + // The API joins houseNumber onto street, so an empty one must be omitted rather than sent + // blank — otherwise the stored address gains a trailing space and stops matching the signed + // registration data. + blocTest( + 'omits houseNumber entirely when it is empty', + setUp: () => when(() => service.setData(any(), any())).thenAnswer((_) async {}), + build: build, + act: (c) => submit(c, houseNumber: ''), + expect: () => const [KycPersonalDataLoading(), KycPersonalDataSuccess()], + verify: (_) => verify( + () => service.setData('https://kyc/data/personal/1', { + 'accountType': 'Personal', + 'firstName': 'Erika', + 'lastName': 'Mueller', + 'phone': '+41790000000', + 'address': { + 'street': 'Bahnhofstrasse', + 'zip': '8001', + 'city': 'Zurich', + 'country': {'id': 41}, + }, + }), + ).called(1), + ); + + blocTest( + 'failure: setData throws → Loading → Failure(e.toString())', + setUp: () => when( + () => service.setData(any(), any()), + ).thenAnswer((_) async => throw Exception('boom')), + build: build, + act: submit, + expect: () => [ + const KycPersonalDataLoading(), + isA().having((s) => s.message, 'message', contains('boom')), + ], + ); + }); + + group('$KycPersonalDataFailure', () { + test('Equatable props cover message', () { + const a = KycPersonalDataFailure('x'); + const b = KycPersonalDataFailure('x'); + const c = KycPersonalDataFailure('y'); + + expect(a, b); + expect(a, isNot(c)); + }); + }); +} diff --git a/test/screens/kyc_bitbox_create_wallet_states_test.dart b/test/screens/kyc_bitbox_create_wallet_states_test.dart index 2fb1438d..4e2b9d44 100644 --- a/test/screens/kyc_bitbox_create_wallet_states_test.dart +++ b/test/screens/kyc_bitbox_create_wallet_states_test.dart @@ -9,13 +9,14 @@ const _testSeed = void main() { group('$KycState', () { - test('KycStep enum has all 10 documented variants', () { + test('KycStep enum has all 11 documented variants', () { // The KycCubit advances through these steps in order; pin the set so a // refactor that drops one surfaces here. expect(KycStep.values.toSet(), { KycStep.email, KycStep.confirmEmail, KycStep.registration, + KycStep.personalData, KycStep.linkWallet, KycStep.legalDisclaimer, KycStep.nationality, From 3cb34376f6ce4a1202eef75ac0de5adcc03d7b1e Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 00:29:04 -0300 Subject: [PATCH 02/12] fix(kyc): never offer the personal-data form to a non-personal account Review follow-ups. The cubit hardcoded accountType: 'Personal'. The API writes that value unconditionally and, whenever it is Personal, explicitly nulls all six organization columns - so submitting this form from an organization or sole-proprietorship account would have destroyed its organization data and dropped five org-only steps from its required set. The account type is now read from the registration payload the parent cubit already fetches, and the form is refused outright for anything that is not personal. The same payload seeds the form. It is a correction form whose copy asks the user to check their details, and every submit rewrites all eight fields, so shipping it empty forced a from-memory re-entry in which a typo could overwrite data that was already correct. houseNumber is always sent: the form requires it, so the omission branch was unreachable, and an omitted key leaves the stored value unchanged rather than clearing it - the opposite of what a correction form should do. Also: build the body with the existing KycPersonalData/KycAddress models instead of a hand-rolled map, reuse the existing personalData string rather than adding an identical key, drop an unreachable submit guard whose comment misdescribed how the form fields validate, and add the widget + golden tests and README row this page owed. --- README.md | 1 + assets/languages/strings_de.arb | 1 - assets/languages/strings_en.arb | 1 - lib/screens/kyc/cubits/kyc/kyc_cubit.dart | 5 +- lib/screens/kyc/kyc_page_manager.dart | 5 +- .../kyc_personal_data_cubit.dart | 38 +-- .../personal_data/kyc_personal_data_page.dart | 76 +++++- .../kyc/kyc_personal_data_golden_test.dart | 85 ++++++ .../steps/kyc_personal_data_page_test.dart | 249 ++++++++++++++++++ .../kyc_personal_data_cubit_test.dart | 29 +- 10 files changed, 429 insertions(+), 61 deletions(-) create mode 100644 test/goldens/screens/kyc/kyc_personal_data_golden_test.dart create mode 100644 test/screens/kyc/steps/kyc_personal_data_page_test.dart diff --git a/README.md b/README.md index 2575e765..f00638c7 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ The transport is USB on Android and Bluetooth on iOS; the original BitBox 02 has | Sell — DFX fiat off-ramp (IBAN) | always | mvp | widget (`sell/sell_page_test.dart`) + golden (`sell/sell_golden_test.dart`, `sell/sell_bank_account_selection_golden_test.dart`); added via [#321](https://github.com/RealUnitCH/app/pull/321) | | KYC: Email + 2FA gate | always | mvp | widget (`kyc_email_page_test.dart`, `kyc_2fa_page_test.dart`) + golden (`kyc/kyc_email_golden_test.dart`, `kyc/kyc_email_verification_golden_test.dart`, `kyc/kyc_2fa_golden_test.dart`); cubit added via [#319](https://github.com/RealUnitCH/app/pull/319) | | KYC: Registration + BitBox EIP-712 sign | always | mvp | widget (`kyc_registration_page_test.dart`) + golden (`kyc/kyc_registration_golden_test.dart`) + unit (`eip712_signer_test.dart`); cubit / `registration_submit` / sign-flow integration tests added via [#319](https://github.com/RealUnitCH/app/pull/319) + [#320](https://github.com/RealUnitCH/app/pull/320) | +| KYC: Personal data (correction after an ident data mismatch) | always | mvp | widget (`kyc_personal_data_page_test.dart`) + golden (`kyc/kyc_personal_data_golden_test.dart`) + cubit (`kyc_personal_data_cubit_test.dart`) | | KYC: Nationality | always | mvp | widget (`kyc_nationality_page_test.dart`) + golden (`kyc/kyc_nationality_golden_test.dart`) | | KYC: Financial data | always | mvp | widget (`kyc_financial_data_page_test.dart`) + golden (`kyc/kyc_financial_data_golden_test.dart`, `kyc/kyc_financial_data_failure_golden_test.dart`, `kyc/kyc_financial_data_loading_golden_test.dart`, `kyc/kyc_financial_data_questions_golden_test.dart`) | | KYC: Ident | always | mvp | widget (`kyc_ident_page_test.dart`) + golden (`kyc/kyc_ident_golden_test.dart`) | diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index c7b73d51..a3769296 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -132,7 +132,6 @@ "kycPendingDescription": "Ihr folgender Schritt ist gerade noch unter Prüfung: ${step}. Bitte haben Sie noch ein wenig Geduld und schauen Sie zu einem späteren Zeitpunkt nochmal rein.", "kycPersonalDataDescription": "Bitte überprüfen Sie Ihre Angaben und korrigieren Sie alles, was nicht mit Ihrem Ausweisdokument übereinstimmt.", "kycPersonalDataFailed": "Persönliche Daten konnten nicht gespeichert werden:\n${message}", - "kycPersonalDataTitle": "Persönliche Daten", "kycSignatureUnsupportedDescription": "Dieses Feature erfordert eine EIP-712-Signatur. Im Debug-Modus (Adresse + Signatur) ist dies technisch nicht möglich. Bitte verwenden Sie eine Software-Wallet oder BitBox, um RealUnit zu nutzen.", "kycSignatureUnsupportedTitle": "Signatur nicht verfügbar", "kycUnsupportedStepDescription": "Der aktuelle KYC-Schritt (${step}) kann in dieser App nicht abgeschlossen werden. Bitte kontaktieren Sie den Support.", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 6df500e8..f6ddb70f 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -132,7 +132,6 @@ "kycPendingDescription": "Your next step is currently being reviewed: ${step}. Please be patient and check back later.", "kycPersonalDataDescription": "Please check your details and correct anything that does not match your identity document.", "kycPersonalDataFailed": "Could not save your personal data:\n${message}", - "kycPersonalDataTitle": "Personal data", "kycSignatureUnsupportedDescription": "This feature requires an EIP-712 signature. The Debug mode (address + signature) cannot produce one. Please use a Software Wallet or a BitBox to use RealUnit.", "kycSignatureUnsupportedTitle": "Signature not available", "kycUnsupportedStepDescription": "The current KYC step (${step}) cannot be completed in this app. Please contact support.", diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart index e738e74f..779131da 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart @@ -256,7 +256,7 @@ class KycCubit extends Cubit { emit(KycPending(step)); return; case KycProcessStatus.inProgress: - await _continueKyc(generation); + await _continueKyc(generation, registrationInfo.realUnitUserDataDto); return; case KycProcessStatus.mergeProcessing: // The user confirmed a merge and the backend is still processing it. @@ -311,7 +311,7 @@ class KycCubit extends Cubit { } /// should only be called after realunit registration was completed - Future _continueKyc(int generation) async { + Future _continueKyc(int generation, [RealUnitUserDataDto? realUnitUserData]) async { final kycStatus = await _kycService.continueKyc(context: _kycContext); if (isClosed || generation != _runGeneration) return; @@ -338,6 +338,7 @@ class KycCubit extends Cubit { KycSuccess( currentStep: kycStep, urlOrToken: currentStep.session.url, + realUnitUserData: realUnitUserData, ), ); } diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index 84c0672f..9154cb7f 100644 --- a/lib/screens/kyc/kyc_page_manager.dart +++ b/lib/screens/kyc/kyc_page_manager.dart @@ -76,7 +76,10 @@ class KycViewManager extends StatelessWidget { }, ), KycStep.registration => KycRegistrationPage(initialUserData: realUnitUserData), - KycStep.personalData => KycPersonalDataPage(url: urlOrToken ?? ''), + KycStep.personalData => KycPersonalDataPage( + url: urlOrToken ?? '', + initialUserData: realUnitUserData, + ), KycStep.linkWallet => KycLinkWalletPage(userData: realUnitUserData), KycStep.nationality => KycNationalityPage(url: urlOrToken ?? ''), KycStep.twoFa => const Kyc2FaPage(), diff --git a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart index 8b0a3670..b9f7eb8f 100644 --- a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart +++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; part 'kyc_personal_data_state.dart'; @@ -25,23 +26,26 @@ class KycPersonalDataCubit extends Cubit { }) async { try { emit(const KycPersonalDataLoading()); - await _kycService.setData(url, { - // The step only ever opens for accounts the app registered itself, and the type dropdown - // offers `human` alone, so the account type is not re-asked here. - 'accountType': 'Personal', - 'firstName': firstName, - 'lastName': lastName, - 'phone': phone, - 'address': { - 'street': street, - // Omitted rather than sent empty: the API treats houseNumber as optional and joins it onto - // street, so a blank one would produce a trailing space in the stored address. - if (houseNumber.isNotEmpty) 'houseNumber': houseNumber, - 'zip': zip, - 'city': city, - 'country': {'id': country.id}, - }, - }); + await _kycService.setData( + url, + KycPersonalData( + // The registration form offers `human` alone, so an account that reached this step through + // the app is always personal. + accountType: KycAccountType.personal, + firstName: firstName, + lastName: lastName, + phone: phone, + address: KycAddress( + street: street, + // Always sent. The form requires it, and an omitted key would leave the stored value + // unchanged rather than clearing it — wrong for a form whose purpose is correction. + houseNumber: houseNumber, + zip: zip, + city: city, + country: country.id, + ), + ).toJson(), + ); emit(const KycPersonalDataSuccess()); } catch (e) { emit(KycPersonalDataFailure(e.toString())); diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart index 8979827b..0151623b 100644 --- a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -1,11 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -21,22 +28,36 @@ import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; /// page existed the API asked for a step the app could not render, and onboarding dead-ended. class KycPersonalDataPage extends StatelessWidget { final String url; + final RealUnitUserDataDto? initialUserData; - const KycPersonalDataPage({super.key, required this.url}); + const KycPersonalDataPage({super.key, required this.url, this.initialUserData}); @override Widget build(BuildContext context) { + // The form can only express a personal account: submitting it sets `accountType` on the + // account, and the API nulls every organization field whenever that value is `Personal`. So an + // organization or sole-proprietorship account must never be offered this form — it would destroy + // its organization data and drop the org-only steps from its required set. Without a payload the + // type is unknown, which is the same refusal case. + final accountType = initialUserData?.kycData.accountType; + if (accountType != KycAccountType.personal) { + return KycFailurePage( + message: S.of(context).kycUnsupportedStepDescription(KycStepName.personalData.value), + ); + } + return BlocProvider( create: (_) => KycPersonalDataCubit(getIt()), - child: KycPersonalDataView(url: url), + child: KycPersonalDataView(url: url, initialUserData: initialUserData!), ); } } class KycPersonalDataView extends StatefulWidget { final String url; + final RealUnitUserDataDto initialUserData; - const KycPersonalDataView({super.key, required this.url}); + const KycPersonalDataView({super.key, required this.url, required this.initialUserData}); @override State createState() => _KycPersonalDataViewState(); @@ -52,6 +73,40 @@ class _KycPersonalDataViewState extends State { final zipCtrl = TextEditingController(); final cityCtrl = TextEditingController(); final countryCtrl = ValueNotifier(null); + Country? _initialCountry; + + @override + void initState() { + super.initState(); + + // This is a correction form, not a fresh capture: the copy asks the user to check their details, + // and every submit rewrites all of them, so shipping it empty would force a from-memory re-entry + // and let a typo overwrite data that was already correct. Seeded from the payload the parent + // cubit already fetched — no extra round-trip. Same shape as KycRegistrationView.initState. + final kycData = widget.initialUserData.kycData; + firstNameCtrl.text = kycData.firstName; + lastNameCtrl.text = kycData.lastName; + phoneCtrl.value = kycData.phone; + streetCtrl.text = kycData.address.street; + houseNumberCtrl.text = kycData.address.houseNumber ?? ''; + zipCtrl.text = kycData.address.zip; + cityCtrl.text = kycData.address.city; + + // The DTO carries only the country id, so the field populates when the lookup resolves; the + // form renders immediately either way. + unawaited(_resolveInitialCountry(kycData.address.country)); + } + + Future _resolveInitialCountry(int countryId) async { + final countries = await getIt().getAllCountries(); + if (!mounted) return; + + final country = countries.where((c) => c.id == countryId).firstOrNull; + if (country == null) return; + + setState(() => _initialCountry = country); + countryCtrl.value = country; + } String? _required(String? value) { if (value == null || value.isEmpty) return ''; @@ -61,31 +116,27 @@ class _KycPersonalDataViewState extends State { void _submit() { FocusManager.instance.primaryFocus?.unfocus(); + // CountryField and PhoneNumberField both register validators with the enclosing Form, so a + // successful validate() already guarantees both notifiers are set. if (!(_formKey.currentState?.validate() ?? false)) return; - // The country field and the phone field validate themselves, but neither writes into the Form, - // so an untouched one leaves its notifier null and would submit an incomplete body. - final country = countryCtrl.value; - final phone = phoneCtrl.value; - if (country == null || phone == null || phone.isEmpty) return; - context.read().submit( url: widget.url, firstName: firstNameCtrl.text, lastName: lastNameCtrl.text, - phone: phone, + phone: phoneCtrl.value!, street: streetCtrl.text, houseNumber: houseNumberCtrl.text, zip: zipCtrl.text, city: cityCtrl.text, - country: country, + country: countryCtrl.value!, ); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(S.of(context).kycPersonalDataTitle)), + appBar: AppBar(title: Text(S.of(context).personalData)), body: BlocListener( listener: (context, state) { if (state is KycPersonalDataSuccess) { @@ -199,6 +250,7 @@ class _KycPersonalDataViewState extends State { CountryField( label: S.of(context).country, purpose: CountryFieldPurpose.residence, + initialValue: _initialCountry, onChanged: (country) => countryCtrl.value = country, ), Padding( diff --git a/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart new file mode 100644 index 00000000..357a329a --- /dev/null +++ b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart @@ -0,0 +1,85 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycPersonalDataCubit extends MockCubit + implements KycPersonalDataCubit {} + +class _MockKycCubit extends MockCubit implements KycCubit {} + +RealUnitUserDataDto _userData() => const RealUnitUserDataDto( + email: 'erika@example.com', + name: 'Erika Mueller', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8001', + addressCity: 'Winterthur', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'EN', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Erika', + lastName: 'Mueller', + phone: '+41790000000', + address: KycAddress( + street: 'Bahnhofstrasse', + houseNumber: '1', + zip: '8001', + city: 'Winterthur', + country: 41, + ), + ), +); + +void main() { + late _MockKycPersonalDataCubit personalDataCubit; + late _MockKycCubit kycCubit; + + setUp(() { + personalDataCubit = _MockKycPersonalDataCubit(); + kycCubit = _MockKycCubit(); + + when(() => personalDataCubit.state).thenReturn(const KycPersonalDataInitial()); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + setUpAll(() { + GetIt.instance.registerSingleton(fixtureCountryService()); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + group('$KycPersonalDataView', () { + goldenTest( + 'seeded from the registration payload', + fileName: 'kyc_personal_data_page_default', + constraints: phoneConstraints, + builder: () => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: personalDataCubit), + BlocProvider.value(value: kycCubit), + ], + child: KycPersonalDataView( + url: 'https://example.com', + initialUserData: _userData(), + ), + ), + ), + ); + }); +} diff --git a/test/screens/kyc/steps/kyc_personal_data_page_test.dart b/test/screens/kyc/steps/kyc_personal_data_page_test.dart new file mode 100644 index 00000000..4921ebc7 --- /dev/null +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -0,0 +1,249 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; +import 'package:realunit_wallet/widgets/form/country_field.dart'; +import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; +import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; + +import '../../../helper/country_fixture.dart'; +import '../../../helper/pump_app.dart'; + +class MockKycPersonalDataCubit extends MockCubit + implements KycPersonalDataCubit {} + +class MockKycCubit extends MockCubit implements KycCubit {} + +class MockDfxKycService extends Mock implements DfxKycService {} + +void main() { + late KycPersonalDataCubit personalDataCubit; + late KycCubit kycCubit; + const url = 'https://example.com/kyc/data/personal/1'; + + RealUnitUserDataDto userDataDto({KycAccountType accountType = KycAccountType.personal}) => + RealUnitUserDataDto( + email: 'erika@example.com', + name: 'Erika Mueller', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8001', + addressCity: 'Winterthur', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'EN', + kycData: KycPersonalData( + accountType: accountType, + firstName: 'Erika', + lastName: 'Mueller', + phone: '+41790000000', + address: const KycAddress( + street: 'Bahnhofstrasse', + houseNumber: '1', + zip: '8001', + city: 'Winterthur', + country: 41, + ), + ), + ); + + setUp(() { + personalDataCubit = MockKycPersonalDataCubit(); + kycCubit = MockKycCubit(); + + when(() => personalDataCubit.state).thenReturn(const KycPersonalDataInitial()); + when(() => personalDataCubit.stream).thenAnswer((_) => const Stream.empty()); + when(() => kycCubit.state).thenReturn(const KycInitial()); + when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value()); + }); + + void setupDependencyInjection() { + final getIt = GetIt.instance; + getIt.registerSingleton(MockDfxKycService()); + getIt.registerSingleton(fixtureCountryService()); + } + + setUpAll(() { + setupDependencyInjection(); + registerFallbackValue( + const Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true), + ); + }); + + tearDownAll(() async => await GetIt.instance.reset()); + + Widget buildSubject(Widget child) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: personalDataCubit), + BlocProvider.value(value: kycCubit), + ], + child: child, + ); + } + + group('$KycPersonalDataPage', () { + testWidgets('renders $KycPersonalDataView', (tester) async { + await tester.pumpApp(KycPersonalDataPage(url: url, initialUserData: userDataDto())); + + expect(find.byType(KycPersonalDataView), findsOne); + }); + }); + + group('$KycPersonalDataPage account-type guard', () { + // Submitting this form sets `accountType` on the account, and the API nulls every organization + // field whenever that value is Personal — so offering the form to a non-personal account would + // destroy its organization data and drop the org-only steps from its required set. + for (final type in [KycAccountType.organization, KycAccountType.soleProprietorship]) { + testWidgets('refuses the form for a $type account', (tester) async { + await tester.pumpApp( + KycPersonalDataPage(url: url, initialUserData: userDataDto(accountType: type)), + ); + + expect(find.byType(KycPersonalDataView), findsNothing); + expect(find.byType(KycFailurePage), findsOne); + }); + } + + testWidgets('refuses the form when the account type is unknown', (tester) async { + await tester.pumpApp(const KycPersonalDataPage(url: url)); + + expect(find.byType(KycPersonalDataView), findsNothing); + expect(find.byType(KycFailurePage), findsOne); + }); + }); + + group('$KycPersonalDataView', () { + 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)); + expect(find.byType(PhoneNumberField), findsOne); + expect(find.byType(CountryField), findsOne); + expect(find.byType(FilledButton), findsOne); + }); + + // The copy asks the user to check their details and every submit rewrites all of them, so an + // empty form would force a from-memory re-entry and let a typo overwrite correct data. + testWidgets('seeds the form from the registration payload', (tester) async { + await tester.pumpApp( + buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())), + ); + await tester.pumpAndSettle(); + + expect(find.text('Erika'), findsOne); + expect(find.text('Mueller'), findsOne); + expect(find.text('Bahnhofstrasse'), findsOne); + expect(find.text('8001'), findsOne); + expect(find.text('Winterthur'), findsOne); + }); + + // Pins the url plumbing: the step's session url is what the submit PUTs to. + testWidgets('submits the seeded values to the step url', (tester) async { + when(() => personalDataCubit.submit( + url: any(named: 'url'), + firstName: any(named: 'firstName'), + lastName: any(named: 'lastName'), + phone: any(named: 'phone'), + street: any(named: 'street'), + houseNumber: any(named: 'houseNumber'), + zip: any(named: 'zip'), + city: any(named: 'city'), + country: any(named: 'country'), + )).thenAnswer((_) async {}); + + await tester.pumpApp( + buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())), + ); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byType(FilledButton)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + verify(() => personalDataCubit.submit( + url: url, + firstName: 'Erika', + lastName: 'Mueller', + phone: '+41790000000', + street: 'Bahnhofstrasse', + houseNumber: '1', + zip: '8001', + city: 'Winterthur', + country: any(named: 'country'), + )).called(1); + }); + + // The step exists so a rejected account can correct its data; submitting an empty form must + // surface the validation errors rather than PUT an incomplete body. + testWidgets('does not submit while a required field is empty', (tester) async { + await tester.pumpApp( + buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.widgetWithText(LabeledTextField, 'Erika'), ''); + await tester.ensureVisible(find.byType(FilledButton)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + verifyNever(() => personalDataCubit.submit( + url: any(named: 'url'), + firstName: any(named: 'firstName'), + lastName: any(named: 'lastName'), + phone: any(named: 'phone'), + street: any(named: 'street'), + houseNumber: any(named: 'houseNumber'), + zip: any(named: 'zip'), + city: any(named: 'city'), + country: any(named: 'country'), + )); + }); + }); + + group('$BlocListener', () { + testWidgets('triggers checkKyc when the submit succeeds', (tester) async { + whenListen( + personalDataCubit, + Stream.fromIterable([const KycPersonalDataSuccess()]), + initialState: const KycPersonalDataInitial(), + ); + + await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto()))); + await tester.pump(); + + verify(() => kycCubit.checkKyc()).called(1); + }); + + testWidgets('shows a SnackBar when the submit fails', (tester) async { + whenListen( + personalDataCubit, + Stream.fromIterable([const KycPersonalDataFailure('fail')]), + initialState: const KycPersonalDataInitial(), + ); + + await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto()))); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + }); + }); +} diff --git a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart index 25c1942b..ce8f1ab3 100644 --- a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart +++ b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart @@ -27,13 +27,13 @@ void main() { KycPersonalDataCubit build() => KycPersonalDataCubit(service); - Future submit(KycPersonalDataCubit c, {String houseNumber = '13'}) => c.submit( + Future submit(KycPersonalDataCubit c) => c.submit( url: 'https://kyc/data/personal/1', firstName: 'Erika', lastName: 'Mueller', phone: '+41790000000', street: 'Bahnhofstrasse', - houseNumber: houseNumber, + houseNumber: '13', zip: '8001', city: 'Zurich', country: _switzerland, @@ -69,31 +69,6 @@ void main() { ).called(1), ); - // The API joins houseNumber onto street, so an empty one must be omitted rather than sent - // blank — otherwise the stored address gains a trailing space and stops matching the signed - // registration data. - blocTest( - 'omits houseNumber entirely when it is empty', - setUp: () => when(() => service.setData(any(), any())).thenAnswer((_) async {}), - build: build, - act: (c) => submit(c, houseNumber: ''), - expect: () => const [KycPersonalDataLoading(), KycPersonalDataSuccess()], - verify: (_) => verify( - () => service.setData('https://kyc/data/personal/1', { - 'accountType': 'Personal', - 'firstName': 'Erika', - 'lastName': 'Mueller', - 'phone': '+41790000000', - 'address': { - 'street': 'Bahnhofstrasse', - 'zip': '8001', - 'city': 'Zurich', - 'country': {'id': 41}, - }, - }), - ).called(1), - ); - blocTest( 'failure: setData throws → Loading → Failure(e.toString())', setUp: () => when( From b979d5007841492e35d3ef33d091f3927998bfd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:30:14 +0200 Subject: [PATCH 03/12] test(goldens): regenerate baselines on the self-hosted runner --- .../macos/kyc_personal_data_page_default.png | Bin 0 -> 38746 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.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 new file mode 100644 index 0000000000000000000000000000000000000000..8c72f9ff274708b3610c98da77cbfd8eae0a07f8 GIT binary patch literal 38746 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¬4oS>TflN1lbb}AKQcn3qs}!?+hOypy&=G$x9Rh} zalF7Kdmi(y1lOF0=-Xo50zX8OoEBP+g`N@hhxyP*(x(~PoDVxL^;k{Cpi58)A?~&9 zPIXr=^!4wa80DxHDsD*;`BL**&l^vj4pJV+S*;f+@mM#mZkYG1C6&-yc^r%}Z&@5S z4r3M0>mI$+q$N0BA3Cov8K*@7rWWFSK7o2g1vw*ER!xxzrta6F+7sm*X}HjXr$fb~ z>y-T;kyWBM!Ke`b!#X^+dPQ+hM+sTV`^(?FXWcKO;WgY26wO{ x^6i4_l+g0O|BnH}|Nn>n2T=F_XD{*v-j~;VuS(0hek0%?NpV@RTv46({|nf4K5GB~ literal 0 HcmV?d00001 From 059e107be83b4fc788b68b0b09b5ce474d68e818 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 01:05:19 -0300 Subject: [PATCH 04/12] fix(kyc): harden the personal-data step against stale and racing prefill Second review pass. Send the account type the page actually gated on instead of a hardcoded Personal, so the submitted payload can never disagree with the value that was checked. The gate reads the registration snapshot, which the API never re-syncs from user_data, so a drifted account type would otherwise still reach a destructive submit. Do not seed a phone number PhoneNumberField cannot decompose. It matches a seeded value against its own prefix list and leaves prefix null otherwise, and in that state it never writes the user's edits back - a correction form would have silently re-submitted the rejected number. The prefix list is now a shared const so callers can check it. A missing registration payload is transient, so it gets a refresh surface rather than the terminal screen an unsupported account type gets, mirroring KycLinkWalletPage. Also: catch country-lookup failures rather than letting them escape as uncaught async errors, do not let a late country lookup clobber a pick the user already made, and correct the RealUnitRegistrationInfoDto doc - it claimed userData is null for alreadyRegistered, which is the only state that reaches this step and would have made the feature dead on arrival. Tests: pin both hops of the payload plumbing (each survived mutation before), the account-type gate, the retry surface and the phone-seeding guard. --- .../real_unit_registration_info_dto.dart | 12 ++- .../kyc_personal_data_cubit.dart | 7 +- .../personal_data/kyc_personal_data_page.dart | 73 +++++++++++++--- lib/widgets/form/phone_number_field.dart | 7 +- test/screens/kyc/kyc_page_manager_test.dart | 85 +++++++++++++++++++ .../steps/kyc_personal_data_page_test.dart | 43 ++++++++-- .../kyc_personal_data_cubit_test.dart | 2 + 7 files changed, 203 insertions(+), 26 deletions(-) diff --git a/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart b/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart index 1a83a9a1..11743d74 100644 --- a/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart +++ b/lib/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart @@ -4,9 +4,15 @@ import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_reg class RealUnitRegistrationInfoDto { /// API-side routing decision for this wallet. Drives KYC dispatch in /// `KycCubit._runCheckKyc` — see CONTRIBUTING.md "API as Decision - /// Authority". `userData` is populated for `addWallet` (prior payload) - /// and `newRegistration` (KYC pre-fill); `null` for `alreadyRegistered` - /// (no UX needed). + /// Authority". `userData` is populated whenever a registration row exists — + /// `alreadyRegistered` and `addWallet` both carry the stored signed payload — + /// and for `newRegistration` when DFX KYC data can pre-fill the form. It is + /// `null` only when the stored registration has no signed payload, or when + /// there is no verified personal data to pre-fill from. + /// + /// `alreadyRegistered` is NOT a no-payload case: the personal-data KYC step + /// is reached only through that branch and seeds its correction form from + /// this payload, so treating it as null would dead-end that step. final RealUnitRegistrationState state; final RealUnitUserDataDto? realUnitUserDataDto; diff --git a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart index b9f7eb8f..d4aab5fe 100644 --- a/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart +++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart @@ -15,6 +15,7 @@ class KycPersonalDataCubit extends Cubit { Future submit({ required String url, + required KycAccountType accountType, required String firstName, required String lastName, required String phone, @@ -29,9 +30,9 @@ class KycPersonalDataCubit extends Cubit { await _kycService.setData( url, KycPersonalData( - // The registration form offers `human` alone, so an account that reached this step through - // the app is always personal. - accountType: KycAccountType.personal, + // Passed in, never assumed: the page refuses to render for anything but a personal + // account, and sending the same value it checked keeps the two from drifting apart. + accountType: accountType, firstName: firstName, lastName: lastName, phone: phone, diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart index 0151623b..ed6dae02 100644 --- a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -19,6 +19,7 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/form/country_field.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; /// The PersonalData KYC step. /// @@ -39,8 +40,13 @@ class KycPersonalDataPage extends StatelessWidget { // organization or sole-proprietorship account must never be offered this form — it would destroy // its organization data and drop the org-only steps from its required set. Without a payload the // type is unknown, which is the same refusal case. - final accountType = initialUserData?.kycData.accountType; - if (accountType != KycAccountType.personal) { + final userData = initialUserData; + if (userData == null) { + // Transient: the registration row carries no signed payload yet. Offer a retry rather than a + // terminal screen, mirroring KycLinkWalletPage's missing-payload surface. + return const _PersonalDataMissingUserDataPage(); + } + if (userData.kycData.accountType != KycAccountType.personal) { return KycFailurePage( message: S.of(context).kycUnsupportedStepDescription(KycStepName.personalData.value), ); @@ -48,7 +54,7 @@ class KycPersonalDataPage extends StatelessWidget { return BlocProvider( create: (_) => KycPersonalDataCubit(getIt()), - child: KycPersonalDataView(url: url, initialUserData: initialUserData!), + child: KycPersonalDataView(url: url, initialUserData: userData), ); } } @@ -86,7 +92,10 @@ class _KycPersonalDataViewState extends State { final kycData = widget.initialUserData.kycData; firstNameCtrl.text = kycData.firstName; lastNameCtrl.text = kycData.lastName; - phoneCtrl.value = kycData.phone; + // Only seed a number PhoneNumberField can decompose: it matches the stored value against its + // own prefix list and leaves `prefix` null otherwise, in which case it never writes edits back. + // An unseeded field defaults to the first prefix and stays editable. + if (phoneNumberPrefixes.any(kycData.phone.startsWith)) phoneCtrl.value = kycData.phone; streetCtrl.text = kycData.address.street; houseNumberCtrl.text = kycData.address.houseNumber ?? ''; zipCtrl.text = kycData.address.zip; @@ -98,14 +107,20 @@ class _KycPersonalDataViewState extends State { } Future _resolveInitialCountry(int countryId) async { - final countries = await getIt().getAllCountries(); - if (!mounted) return; + try { + final countries = await getIt().getAllCountries(); + if (!mounted) return; - final country = countries.where((c) => c.id == countryId).firstOrNull; - if (country == null) return; + final country = countries.where((c) => c.id == countryId).firstOrNull; + // Never clobber a pick the user already made: CountryField runs its own lookup, and if that one + // resolves first the field is live before this seed arrives. + if (country == null || countryCtrl.value != null) return; - setState(() => _initialCountry = country); - countryCtrl.value = country; + setState(() => _initialCountry = country); + countryCtrl.value = country; + } catch (_) { + // Degrade to an empty picker — CountryField renders its own error and retry. + } } String? _required(String? value) { @@ -116,12 +131,14 @@ class _KycPersonalDataViewState extends State { void _submit() { FocusManager.instance.primaryFocus?.unfocus(); - // CountryField and PhoneNumberField both register validators with the enclosing Form, so a - // successful validate() already guarantees both notifiers are set. + // CountryField registers a validator with the enclosing Form, so a successful validate() + // guarantees countryCtrl is set. phoneCtrl is guaranteed by PhoneNumberField itself: it defaults + // to its first prefix when unseeded and only accepts a seeded value it can decompose. if (!(_formKey.currentState?.validate() ?? false)) return; context.read().submit( url: widget.url, + accountType: widget.initialUserData.kycData.accountType, firstName: firstNameCtrl.text, lastName: lastNameCtrl.text, phone: phoneCtrl.value!, @@ -288,3 +305,35 @@ class _KycPersonalDataViewState extends State { super.dispose(); } } + +/// Shown when the step is actionable but the registration row carries no signed payload to seed the +/// form from. Transient, so it offers a refresh rather than dead-ending. +class _PersonalDataMissingUserDataPage extends StatelessWidget { + const _PersonalDataMissingUserDataPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).personalData)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Text( + S.of(context).kycFailure, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + actions: [ + AppFilledButton( + onPressed: () => context.read().checkKyc(), + label: S.of(context).refresh, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 469cf364..b6e02390 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -3,6 +3,11 @@ 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'; +/// The dial prefixes the field can decompose a stored number into. A seeded value that starts with +/// none of these leaves `prefix` null, and the field then never writes edits back to the controller — +/// so callers that pre-fill the controller must check against this list first. +const phoneNumberPrefixes = ['+41', '+49']; + class PhoneNumberField extends StatefulWidget { final ValueNotifier controller; @@ -13,7 +18,7 @@ class PhoneNumberField extends StatefulWidget { } class _PhoneNumberFieldState extends State { - final prefixes = ['+41', '+49']; + final prefixes = phoneNumberPrefixes; String? prefix; String? number; diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart index af5204ce..46ff668c 100644 --- a/test/screens/kyc/kyc_page_manager_test.dart +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -4,6 +4,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart'; @@ -77,6 +81,34 @@ KycStepSessionDto _currentStep( void main() { late _MockDfxKycService kycService; late _MockRealUnitRegistrationService registrationService; + + RealUnitUserDataDto personalUserData() => const RealUnitUserDataDto( + email: 'erika@example.com', + name: 'Erika Mueller', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8001', + addressCity: 'Winterthur', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'EN', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Erika', + lastName: 'Mueller', + phone: '+41790000000', + address: KycAddress( + street: 'Bahnhofstrasse', + houseNumber: '1', + zip: '8001', + city: 'Winterthur', + country: 41, + ), + ), + ); late _MockRealUnitLegalService legalService; late _MockAppStore appStore; late _MockAWallet wallet; @@ -101,6 +133,59 @@ void main() { ); }); + // The personal-data step only renders its correction form when the registration payload reaches it. + // Both hops are easy to drop silently — `_continueKyc` forwarding it into `KycSuccess`, and the + // manager passing it to the page — and either one makes the gate refuse for EVERY account, which is + // exactly the dead end this step exists to remove. + testWidgets( + 'KycSuccess(personalData) renders the form, not the refusal screen', + (tester) async { + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => RealUnitRegistrationInfoDto( + state: RealUnitRegistrationState.alreadyRegistered, + realUnitUserDataDto: personalUserData(), + ), + ); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level20, + processStatus: KycProcessStatus.inProgress, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => kycService.continueKyc()).thenAnswer( + (_) async => _session( + level: KycLevel.level20, + steps: const [], + currentStep: _currentStep(KycStepName.personalData), + ), + ); + + // the page resolves both from getIt + final getIt = GetIt.instance; + getIt.registerSingleton(kycService); + getIt.registerSingleton(fixtureCountryService()); + addTearDown(() async => getIt.reset()); + + final cubit = KycCubit(kycService, registrationService, legalService, appStore); + await tester.pumpApp( + BlocProvider.value(value: cubit, child: const KycViewManager()), + ); + + await cubit.checkKyc(); + await tester.pumpAndSettle(); + + expect(cubit.state, isA()); + expect((cubit.state as KycSuccess).currentStep, KycStep.personalData); + // the payload survived both hops + expect((cubit.state as KycSuccess).realUnitUserData, isNotNull); + expect(find.byType(KycPersonalDataView), findsOneWidget); + expect(find.byType(KycFailurePage), findsNothing); + + await cubit.close(); + }, + ); + // An in-progress `dfxApproval` step used to land on a blank white Scaffold // (the `(_) => const Scaffold()` fallback in KycViewManager). It must render // the existing pending page instead. 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 4921ebc7..ec036a1d 100644 --- a/test/screens/kyc/steps/kyc_personal_data_page_test.dart +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -32,12 +32,14 @@ void main() { late KycCubit kycCubit; const url = 'https://example.com/kyc/data/personal/1'; - RealUnitUserDataDto userDataDto({KycAccountType accountType = KycAccountType.personal}) => - RealUnitUserDataDto( + RealUnitUserDataDto userDataDto({ + KycAccountType accountType = KycAccountType.personal, + String phone = '+41790000000', + }) => RealUnitUserDataDto( email: 'erika@example.com', name: 'Erika Mueller', type: 'HUMAN', - phoneNumber: '+41790000000', + phoneNumber: phone, birthday: '1990-01-01', nationality: 'CH', addressStreet: 'Bahnhofstrasse 1', @@ -50,7 +52,7 @@ void main() { accountType: accountType, firstName: 'Erika', lastName: 'Mueller', - phone: '+41790000000', + phone: phone, address: const KycAddress( street: 'Bahnhofstrasse', houseNumber: '1', @@ -82,6 +84,7 @@ void main() { registerFallbackValue( const Country(id: 41, symbol: 'CH', name: 'Switzerland', kycAllowed: true), ); + registerFallbackValue(KycAccountType.personal); }); tearDownAll(() async => await GetIt.instance.reset()); @@ -119,11 +122,23 @@ void main() { }); } - testWidgets('refuses the form when the account type is unknown', (tester) async { - await tester.pumpApp(const KycPersonalDataPage(url: url)); + // A missing payload is transient (the registration row has no signed payload yet), so it gets a + // refresh rather than the terminal screen an unsupported account type gets. + testWidgets('offers a retry, not a dead end, when the payload is missing', (tester) async { + await tester.pumpApp( + BlocProvider.value( + value: kycCubit, + child: const KycPersonalDataPage(url: url), + ), + ); expect(find.byType(KycPersonalDataView), findsNothing); - expect(find.byType(KycFailurePage), findsOne); + expect(find.byType(KycFailurePage), findsNothing); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + verify(() => kycCubit.checkKyc()).called(1); }); }); @@ -154,10 +169,22 @@ void main() { expect(find.text('Winterthur'), findsOne); }); + testWidgets('does not seed a phone whose prefix the field cannot decompose', (tester) async { + final dto = userDataDto(phone: '+33612345678'); + await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: dto))); + await tester.pumpAndSettle(); + + // Left unseeded, so PhoneNumberField falls back to its first prefix and stays editable. Seeding + // it would leave `prefix` null — the dropdown blank — and the field would then never write the + // user's edits back to the controller. + expect(find.text(phoneNumberPrefixes.first), findsOne); + }); + // Pins the url plumbing: the step's session url is what the submit PUTs to. testWidgets('submits the seeded values to the step url', (tester) async { when(() => personalDataCubit.submit( url: any(named: 'url'), + accountType: any(named: 'accountType'), firstName: any(named: 'firstName'), lastName: any(named: 'lastName'), phone: any(named: 'phone'), @@ -180,6 +207,7 @@ void main() { verify(() => personalDataCubit.submit( url: url, + accountType: KycAccountType.personal, firstName: 'Erika', lastName: 'Mueller', phone: '+41790000000', @@ -207,6 +235,7 @@ void main() { verifyNever(() => personalDataCubit.submit( url: any(named: 'url'), + accountType: any(named: 'accountType'), firstName: any(named: 'firstName'), lastName: any(named: 'lastName'), phone: any(named: 'phone'), diff --git a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart index ce8f1ab3..f5b57bd0 100644 --- a/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart +++ b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; class _MockKycService extends Mock implements DfxKycService {} @@ -29,6 +30,7 @@ void main() { Future submit(KycPersonalDataCubit c) => c.submit( url: 'https://kyc/data/personal/1', + accountType: KycAccountType.personal, firstName: 'Erika', lastName: 'Mueller', phone: '+41790000000', From bae74e31160337a0b2c99075e363dac44dea5102 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 01:07:13 -0300 Subject: [PATCH 05/12] test(goldens): cover the personal-data missing-payload surface Mirrors the link-wallet defensive refresh page, which carries its own baseline for the same shape. --- .../screens/kyc/kyc_personal_data_golden_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart index 357a329a..79d3328b 100644 --- a/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart @@ -64,6 +64,20 @@ void main() { tearDownAll(() async => GetIt.instance.reset()); group('$KycPersonalDataView', () { + // initialUserData == null → the page short-circuits to its defensive refresh surface. No cubit is + // created, so drive the page directly with only the parent KycCubit in scope for the handler. + goldenTest( + 'missing user data — defensive refresh page', + fileName: 'kyc_personal_data_page_missing_user_data', + constraints: phoneConstraints, + builder: () => wrapForGolden( + BlocProvider.value( + value: kycCubit, + child: const KycPersonalDataPage(url: 'https://example.com'), + ), + ), + ); + goldenTest( 'seeded from the registration payload', fileName: 'kyc_personal_data_page_default', From 047f84300d76825d5cff14f8f1e821f465aab008 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:08:20 +0200 Subject: [PATCH 06/12] test(goldens): regenerate baselines on the self-hosted runner --- .../kyc_personal_data_page_missing_user_data.png | Bin 0 -> 8872 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_missing_user_data.png diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_missing_user_data.png b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_missing_user_data.png new file mode 100644 index 0000000000000000000000000000000000000000..eab60d884e57b3239c56e9d3114a89c01a980334 GIT binary patch literal 8872 zcmeHNX;f3$mX427$`Mc|Wh&(-E2`td*Q~Z}z!&pMAdl?Qh?8 zQ*S!h9X@d400;y+eC?{uEf8o=G6*Dd{o8%O8MlxFn!xZ)#N}(ZzXd+1Z||l6$9p1f z*%6{?FYK8^79860Qf_W;iQcLB(Xrg2NY5`52t4k`hxf4&9%aO%+iI5578_bhI2Hy z{jIuiZSaBOPa~a%Wodj;@bm>7ojN$3Nr|v?aXG7aUD3tW8|iufCq;Ie=#aEBOst6K*5%iu>rJ2fctQ>efsR$Vk0h05xN#Y2+^@XjwZWPg z(FT+|+(=Cm2m}Ud`TGX`I0{x5sL=Io=#`Z^a_eVATWCK!c)8PX6LTpHMCT`#g6qMlk1Ur z)6xwUBu&;aeHcNZTw?hsMzEmH9cxsfI3^!6ce)3I*~z141lLNPPz#Mk`2EL(_!#vE zZnX6$MLi>qRNNCsD0KIVET710*Au=LSI0&+lIRx34wigg_Iadq_q(}HLhGY*?*#^? zIC@9NA-Cw;Z{$iFPLawJUa{^DGkTEkeC^0hvRYV#<^9WC^pq!#kdqR=y-#Y9L_8Kr1WTvKOJd-h*14%l1gBGnOtIQp%8sY+ zogLE(MEo?h@K%Ff+{{HwRIeQ~RVUzzjm>tuAlo9wJOBQAMLnI2W{wrj z9!50-SW^G) zI$v#u=WaWk`)k3(-8q*%Jy{cgz&l`_jMqbbsftKz$pJR^F9(zjJa||<>kY@82cvY= zrx~<0-Cxo%&{aH;^!D)!`-8hvXsGv&Y2#MhF9Uzw!gMy$zPN5LwJ({%v7qmGb48O8 z3yR5ywA{0~?@B!(2GQ!xhF`Jf=H@06I~yB_VdDl|EZypNO>-U0g<53lxj!z0F20t* zV!&W9zOy$2SH|b``>bI!F_2TIhB`kp82-WIHMpFM0WYRC)@v{mB}hCyxRycJSv560 zb!q|ZCSQ7GRax#vktW}o&8&(5UmY1JgzC&%#z_Z*z@ zp>A?TaJ%%BloaFOv9RGy@38s)XGlr`DP5;9|M>e%gETwM1U*5{`QixLhKh|1Ig!l) zfVkMwsegm?g4*|G$EO`b#Gg=7a1uZNM(x->lVDasOa>UU{?3BR? z!0#1*m;`phq$)8jYqXAV67ZnC55i_YDx2ohgUa=DeMYY!Dr(jg>Jy{v6B6vIyk`^> zI8e*74mLc6nm1>So_LSgn3ib@9k6^QsB|4DJdtd_W^<89mgwz6A~rjrXM>MsrxNV)QTq0 zJ<>2gz3+&+y!1g&7K0;nz%48+#6DQ4a;4b^+UGy`m|7nn<&2ucf_tkb8%vAp4cCa$ zU$ZwpIZqprY7=+z4AH&X^{6dut3agH(^{XU~Vj_OY>Y~C_qdC-CA8#B%*nlB(FGASG-fHGiW!I~8N{Ueg8K(!@iw|@BbU{y>Lwq0+z zxG3)iWSRY_cRRz<0Lb<4flN5+y0a}TLG3gJPQH8ISKn?!vPR4aA~_%=iO;Tm$0NLMp9HbzbqJ7W>(YSx!|;nF<*c!!rez(JA1 z?_wkFg@t7-EEo^h`j8mCljFgo1laKKumXBppx>1pp4Qyl90*u+p|`^XBA_jOp9#hT z%(4h~6`Eh68GCqmlyr7NcY)cSOs(}Aq*36<}OKkvDXaYTJ{ki?W*r+9-`m8-*WWn+RhP0iOX z71cE<#hsOitMI5pt8k6(ZC*-WcLL={A}>|*S$Kz#wfive!^}fKZxk0BCmILG(JqGj z(}XDNrYiR0+Q~gnZ{z#d%>uJ54gFgZkpqP?epflmYnr#nY9|lrj#Z_FOVNomBZO1a zE7Hjmd+TOKZ)Z{_x8-mL&&c8SbQ0R5^k$Tk0dF?vWWeOn5oHhPH+A0dDBFCixWIg= zK=iV*W--$+(q6UDqwCC2JHiF>3NQBQz4VKw1dH+wSR@NAXjfkiFzL)Bf(${<2d+8*0oAOm+)YdGFUu-j>xs zn(TsUSeE}Y$RV&%Q?Gl>0Jix^B_7la^RzoT)P->A`0&AF-T#@B#0Wt(r%2Y+f$f1Kod&Dqmy z`}5mdgPM09MXf$eD=GGe_bvD|oJUE!_6~n09st2>fKw<=mj(#OB=PM|>$$nSqX z{DX~`^z&P{j&>up68odc^g-{r#h0MdP0?^W8kE)Zcz|f+o84HV*tqwO_KW!H)%Ej6 z%}o?~k>}`WRHtyqz=K8N-eoe7Gvja&KHLUEzWw!a5#VUI_Y zBI8sgMnYQ_Y~n}Q(`P-<0FIil(A3Z}=3UB-8mo{`4#=e+yBQJA(W{x<&@jECnZSI! z*M;xbNOwqPiL}@Hxs+-hTpFps!m!3F5q~s8(c9fK;iN3+P@ZcRv;HBpkZOpKB_GRj$Vjzjw@0FC{YOQei_N^y!MA)h$xl1}+OfuRq!0W@ zeT(Z6mR2Ofi^_n$Jo4ldbHq(IlE*@Jy$mDE(SW|ZmEVFv^C~OS6QM1f@B8FBmgip9 zoW+j`^b?Aun{daT`55Y}(!DbT#oD&FKV+$Xs3f#@?Tp!Fk^2%j>MC02SB$&we_51p zAC}qy(v*@2)v+`Wy1L>@-?}zO(V|^6H$PyA7+yA$=-?^5G#y(~Zok>mm=0F3YLcl`fv%P+(KJ78@V?xQiuan_QkPvd32&aWfP~yMRE7 zYl*BYusC{IA|yhz%8vHs6D(l;8{zW0tGqF^5%pP|@JDWDmj=M)sUEB&vy1qm!5WSehZIc=XS zKLY`11UbV^u%QyxTUWU+xRf?s1nti$2EUqZUeZ>rePS%ShA~+Y;Lm%h=eSW-bC&+4 zp*iESI;150c4k)3Mx{qd6wr2Qe9}#f9mrPYo>R+`9d)q<7;4dT>!yQ+Fh#+vEg0Azt&@e0@`eK0&viIYZ%r%`*2PtRkK^Hb zOZng_bH-C??9vFexKz{wkTKBI_46z!6zhDPS=-$91O7Z@tb^L7~3=6|_rSI0NjgBYsHg2d(Vi zz4nY`oyaO59DS-{k?vvZE%PJ3|l?5tsf59?kHVZC9{!K%*0E|fW==u-#xePsMH z`u*%m2+lyRd*~I6%RAsZ@rU|_1|PBrO&Aa5xx6YP0PP;5A?64kzay$J8ssHaA{Zaq z&F)>7Zhr87$Y`cyg4VdZ!~MQo5N182(`~O6D^6_4*XSm8>w4eP>#HK24n1xp$6PycdUD z3NM`cBAOoz4f1@K+uUI~Qsp1a_}7f6XL(Fuw%%)Jnv>{@@b<6s!>z_eb}dA|GN5mE z_*$!5Bp;*5VSz$^)`pzTfyfgvYzc!pIAS69JoZ!eo}gT%;IW=PUf#Fs9CCeB?@VQfkAj+%R-_Q``@1u z)kyC~2=kSYUyVl)5>d>&EPk{(M#Vb@qVaYNC>V?cT5>$5>l6FOe&a(i+$L> zj0N<|#9dL=qi;%VYTHh4l0WdZf@7KH;X5jmg>E04`XpjkhXSpD>+0&ewD7@c$0SD} zwg{l4+Y9i&b`ls_?Fb?t*L%ZO-K9x#w9~4$7^0%IjokDMHV^XI40B%HzRd*fGK7bj=SJ7fhSTbEc4W z!ut+PqBUfLF{Rq(d_%l8(8+9r=>y{O^c6v6YC*h9@4IjsX;k>IrR%@Q?HYetI8BHI zsBqpenl+>{!`AX>>f;|&^GsuvcP{=SI$P!Q{!_gKtdW!=C+2Y%&qj91V}x23BFcHJCtO z?rZ-f#(=prHVkHey-o551FdP*Y9gqtO>5Uw;=a3*aJ4hnx+YISPCqamC_U+m)7nQ= zXLWW-nx+H}EuD&Oy>!YDa2c0*(L9zwbRLP(;`5^uKGJWxhNy{3DR}4 zOh^m7gC{&nE25?yRgJ40@v39x=7nBU>Nrm%qhh^b=zNvOwB~)vEc2g-I2xVKA>Cn2P)Kn@irIsKyt9WMvI)#5993_u zyNtX-5sLElG3wFDpOkmvhxJ>-B#Oct!Zg~Ym-}|Ac zr5R%Au0lVJW}(B=xDQ(-tv=I!#NSVzVgNdkS!tJ!B^)SH2Q}Y@*}9{c?`D{Q+-v?k z1~2{RBQl^14Fc0Ih8G;dF(K1zJHwr8m6q2m zEZH-w*t_bbW_8JzT5Xwi)parRJXzF;hD$%ZZGEu(o7=0ga@k)}hK|{wS6=%f*Cf`& z(9wjJB;SXyU<>>+Y3U=COSetyx(O9}_z0oa2^Ec-K9GbvF-wB;Azv?B2aDs!)>{K+ z`vD$($fU}I$L(g`Yq*HKyLa^oM^a99(p3Xqqq=|hMf^7{U0_tOUI+@U1K+cF z;qw~vWWk#F_-wh|bU7LUT}rE(fcc-_Q{7xn|Mnt-m^I@r`)zVq9E E03O!bn*aa+ literal 0 HcmV?d00001 From 0af88484774eabf9bd6cc15f50c3379a317b60cc Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 01:36:54 -0300 Subject: [PATCH 07/12] fix(kyc): fall back to a usable prefix in PhoneNumberField Third review pass. The previous pass exported the prefix list and made callers guard against seeding a value the field cannot decompose. That documented the bug as a contract instead of fixing it, and left the other seeding caller - the registration prefill, which is the path that provably carries arbitrary dial codes - in violation of the contract this PR itself introduced. Fix it where it belongs. An undecomposable seed no longer leaves prefix null: the dropdown carries no validator, so Form.validate() passed while updatePhoneNumber() refused to write, and the stale value was submitted instead of what the user typed. Falling back to the first prefix keeps the field editable and lets the number validator block submit until it is re-entered. The caller-side guard and the exported const are gone. Pin the two pass-2 fixes that were silently reversible - the country-lookup catch and the racing-pick guard - and correct two comments that the pass-2 split left describing the old behaviour. --- lib/screens/kyc/cubits/kyc/kyc_state.dart | 4 +- .../personal_data/kyc_personal_data_page.dart | 8 +- lib/widgets/form/phone_number_field.dart | 28 ++++--- .../steps/kyc_personal_data_page_test.dart | 73 +++++++++++++++++-- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/lib/screens/kyc/cubits/kyc/kyc_state.dart b/lib/screens/kyc/cubits/kyc/kyc_state.dart index a85bddf2..dac1f512 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_state.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_state.dart @@ -44,7 +44,9 @@ class KycSuccess extends KycState { /// Server-side user record attached to the routing decision. Populated when /// `RealUnitRegistrationService.getRegistrationInfo()` returns userData - /// alongside the state (`AddWallet` always, `NewRegistration` when the + /// alongside the state (`AddWallet` always, `AlreadyRegistered` whenever the + /// registration carries a signed payload — which is what the `personalData` + /// step seeds its correction form from — and `NewRegistration` when the /// backend has fallback data). The cubit forwards the DTO so downstream /// pages do not need to re-fetch — see CONTRIBUTING.md "Single round-trip /// per decision". diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart index ed6dae02..ecefde20 100644 --- a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -38,8 +38,7 @@ class KycPersonalDataPage extends StatelessWidget { // The form can only express a personal account: submitting it sets `accountType` on the // account, and the API nulls every organization field whenever that value is `Personal`. So an // organization or sole-proprietorship account must never be offered this form — it would destroy - // its organization data and drop the org-only steps from its required set. Without a payload the - // type is unknown, which is the same refusal case. + // its organization data and drop the org-only steps from its required set. final userData = initialUserData; if (userData == null) { // Transient: the registration row carries no signed payload yet. Offer a retry rather than a @@ -92,10 +91,7 @@ class _KycPersonalDataViewState extends State { final kycData = widget.initialUserData.kycData; firstNameCtrl.text = kycData.firstName; lastNameCtrl.text = kycData.lastName; - // Only seed a number PhoneNumberField can decompose: it matches the stored value against its - // own prefix list and leaves `prefix` null otherwise, in which case it never writes edits back. - // An unseeded field defaults to the first prefix and stays editable. - if (phoneNumberPrefixes.any(kycData.phone.startsWith)) phoneCtrl.value = kycData.phone; + phoneCtrl.value = kycData.phone; streetCtrl.text = kycData.address.street; houseNumberCtrl.text = kycData.address.houseNumber ?? ''; zipCtrl.text = kycData.address.zip; diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index b6e02390..a2684db8 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -3,11 +3,6 @@ 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'; -/// The dial prefixes the field can decompose a stored number into. A seeded value that starts with -/// none of these leaves `prefix` null, and the field then never writes edits back to the controller — -/// so callers that pre-fill the controller must check against this list first. -const phoneNumberPrefixes = ['+41', '+49']; - class PhoneNumberField extends StatefulWidget { final ValueNotifier controller; @@ -18,7 +13,7 @@ class PhoneNumberField extends StatefulWidget { } class _PhoneNumberFieldState extends State { - final prefixes = phoneNumberPrefixes; + final prefixes = ['+41', '+49']; String? prefix; String? number; @@ -26,17 +21,20 @@ class _PhoneNumberFieldState extends State { void initState() { super.initState(); final value = widget.controller.value; - if (value != null) { - for (var p in prefixes) { - if (value.startsWith(p)) { - prefix = p; - number = value.substring(p.length); - break; - } + for (final p in prefixes) { + if (value != null && value.startsWith(p)) { + prefix = p; + number = value.substring(p.length); + break; } - } else { - prefix = prefixes.first; } + + // 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, + // so the validator still blocks submit until it is re-entered. + prefix ??= prefixes.first; } void updatePhoneNumber() { 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 ec036a1d..9093ec43 100644 --- a/test/screens/kyc/steps/kyc_personal_data_page_test.dart +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; @@ -169,15 +172,75 @@ void main() { expect(find.text('Winterthur'), findsOne); }); - testWidgets('does not seed a phone whose prefix the field cannot decompose', (tester) async { + // A stored number with a dial code the field does not offer must still leave an editable field: + // PhoneNumberField falls back to its first prefix rather than leaving `prefix` null, which would + // render a blank dropdown and make it silently drop every edit the user makes. + testWidgets('stays editable when the stored phone has an unsupported prefix', (tester) async { final dto = userDataDto(phone: '+33612345678'); await tester.pumpApp(buildSubject(KycPersonalDataView(url: url, initialUserData: dto))); await tester.pumpAndSettle(); - // Left unseeded, so PhoneNumberField falls back to its first prefix and stays editable. Seeding - // it would leave `prefix` null — the dropdown blank — and the field would then never write the - // user's edits back to the controller. - expect(find.text(phoneNumberPrefixes.first), findsOne); + expect(find.text('+41'), findsOne); + }); + + // The country lookup is fire-and-forget; without a catch a failing GET escapes as an uncaught + // async error instead of degrading to an empty picker. + testWidgets('survives a failing country lookup', (tester) async { + final getIt = GetIt.instance; + await getIt.reset(); + getIt.registerSingleton(MockDfxKycService()); + getIt.registerSingleton(failingCountryService()); + addTearDown(() async { + await getIt.reset(); + setupDependencyInjection(); + }); + + await tester.pumpApp( + buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())), + ); + await tester.pumpAndSettle(); + + // the rest of the form still rendered + expect(find.text('Erika'), findsOne); + }); + + // Two independent country lookups race — this page's and CountryField's own. If this one is the + // slower, it must not overwrite a country the user has already chosen in the meantime. + testWidgets('does not overwrite a country the user already picked', (tester) async { + final gate = Completer(); + var served = 0; + final getIt = GetIt.instance; + await getIt.reset(); + getIt.registerSingleton(MockDfxKycService()); + getIt.registerSingleton( + countryServiceWithClient( + MockClient((_) async { + // hold only the first caller (this page); let CountryField's own load through + if (served++ == 0) await gate.future; + return countriesFixtureResponse(); + }), + ), + ); + addTearDown(() async { + await getIt.reset(); + setupDependencyInjection(); + }); + + await tester.pumpApp( + buildSubject(KycPersonalDataView(url: url, initialUserData: userDataDto())), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CountryField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Germany').last); + await tester.pumpAndSettle(); + + gate.complete(); + await tester.pumpAndSettle(); + + expect(find.text('Germany'), findsOne); + expect(find.text('Switzerland'), findsNothing); }); // Pins the url plumbing: the step's session url is what the submit PUTs to. From 5fd613c9a854c51f5177b799892c32e62b848d38 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 14:46:04 -0300 Subject: [PATCH 08/12] feat(kyc): replace the unsupported-step dead end with an actionable handoff The app renders 6 of the 24 step names the API can return; every other one fell to the generic failure page, which carries no actions at all, with the step's raw wire identifier printed into the message. The user was left with nothing to do and nothing useful to tell support. The new page offers a retry and a route to support, and names no step. The retry matters because the state is not always terminal - an internal review completing or a step expiring moves the account on by itself, and re-reading the API is the only way the user finds out. The identifier is gone deliberately: it is an internal enum value, not something a user can act on. The organization refusal on the personal-data step now renders the same page, so there is one answer for 'this step cannot be shown here' instead of two. Covers Recommendation, ResidencePermit and every future step name at once. --- README.md | 2 +- assets/languages/strings_de.arb | 4 +- assets/languages/strings_en.arb | 4 +- lib/screens/kyc/kyc_page_manager.dart | 7 +- .../personal_data/kyc_personal_data_page.dart | 9 ++- .../subpages/kyc_unsupported_step_page.dart | 64 +++++++++++++++++++ .../kyc/kyc_unsupported_step_golden_test.dart | 33 ++++++++++ test/screens/kyc/kyc_page_manager_test.dart | 14 ++-- .../steps/kyc_personal_data_page_test.dart | 6 +- .../kyc_unsupported_step_page_test.dart | 62 ++++++++++++++++++ 10 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 lib/screens/kyc/subpages/kyc_unsupported_step_page.dart create mode 100644 test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart create mode 100644 test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart diff --git a/README.md b/README.md index f00638c7..abdc459b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The transport is USB on Android and Bluetooth on iOS; the original BitBox 02 has | KYC: Financial data | always | mvp | widget (`kyc_financial_data_page_test.dart`) + golden (`kyc/kyc_financial_data_golden_test.dart`, `kyc/kyc_financial_data_failure_golden_test.dart`, `kyc/kyc_financial_data_loading_golden_test.dart`, `kyc/kyc_financial_data_questions_golden_test.dart`) | | KYC: Ident | always | mvp | widget (`kyc_ident_page_test.dart`) + golden (`kyc/kyc_ident_golden_test.dart`) | | KYC: Pending / Completed / Failure | always | mvp | widget (`kyc/subpages/kyc_*_page_test.dart`) + golden (`kyc/kyc_pending_golden_test.dart`, `kyc/kyc_completed_golden_test.dart`, `kyc/kyc_failure_golden_test.dart`, `kyc/kyc_loading_golden_test.dart`) | -| KYC: AccountMergeRequested / UnsupportedStepFailure | always | mvp | golden (`kyc/kyc_account_merge_golden_test.dart`); cubit paths added via [#319](https://github.com/RealUnitCH/app/pull/319) | +| KYC: AccountMergeRequested / UnsupportedStepFailure | always | mvp | widget (`kyc/subpages/kyc_unsupported_step_page_test.dart`) + golden (`kyc/kyc_account_merge_golden_test.dart`, `kyc/kyc_unsupported_step_golden_test.dart`); cubit paths added via [#319](https://github.com/RealUnitCH/app/pull/319) | | `DFXAuthService` (lazy auth + 401 retry) | always | mvp | — (unit tests added via [#319](https://github.com/RealUnitCH/app/pull/319) + [#321](https://github.com/RealUnitCH/app/pull/321)) | | `balance_service` (balance fetch + cache) | always | mvp | unit (`balance_service_test.dart`) | | `format_fixed` / `parse_fixed` (decimal helpers) | always | mvp | unit (`format_fixed_test.dart`, `parse_fixed_test.dart`) | diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index a3769296..0d193aea 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -134,7 +134,9 @@ "kycPersonalDataFailed": "Persönliche Daten konnten nicht gespeichert werden:\n${message}", "kycSignatureUnsupportedDescription": "Dieses Feature erfordert eine EIP-712-Signatur. Im Debug-Modus (Adresse + Signatur) ist dies technisch nicht möglich. Bitte verwenden Sie eine Software-Wallet oder BitBox, um RealUnit zu nutzen.", "kycSignatureUnsupportedTitle": "Signatur nicht verfügbar", - "kycUnsupportedStepDescription": "Der aktuelle KYC-Schritt (${step}) kann in dieser App nicht abgeschlossen werden. Bitte kontaktieren Sie den Support.", + "kycUnsupportedStepContactSupport": "Support kontaktieren", + "kycUnsupportedStepDescription": "Der nächste Schritt Ihrer Verifizierung kann in dieser App nicht abgeschlossen werden. Aktualisieren Sie, um zu prüfen, ob er weitergelaufen ist, oder wenden Sie sich an unser Support-Team, das dann übernimmt.", + "kycUnsupportedStepTitle": "Dieser Schritt wird anderswo fortgesetzt", "label": "Bezeichnung", "languageEnglish": "Englisch", "languageGerman": "Deutsch", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index f6ddb70f..50fc2a56 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -134,7 +134,9 @@ "kycPersonalDataFailed": "Could not save your personal data:\n${message}", "kycSignatureUnsupportedDescription": "This feature requires an EIP-712 signature. The Debug mode (address + signature) cannot produce one. Please use a Software Wallet or a BitBox to use RealUnit.", "kycSignatureUnsupportedTitle": "Signature not available", - "kycUnsupportedStepDescription": "The current KYC step (${step}) cannot be completed in this app. Please contact support.", + "kycUnsupportedStepContactSupport": "Contact support", + "kycUnsupportedStepDescription": "The next step of your verification cannot be completed in this app. Refresh to check whether it has moved on, or contact our support team and they will take it from here.", + "kycUnsupportedStepTitle": "This step continues elsewhere", "label": "Label", "languageEnglish": "English", "languageGerman": "German", diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index 9154cb7f..4f75ca0a 100644 --- a/lib/screens/kyc/kyc_page_manager.dart +++ b/lib/screens/kyc/kyc_page_manager.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; @@ -24,6 +22,7 @@ import 'package:realunit_wallet/screens/kyc/subpages/kyc_loading_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_manual_review_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_merge_processing_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -56,9 +55,7 @@ class KycViewManager extends StatelessWidget { KycLoading() => const KycLoadingPage(), KycFailure(:final message) => KycFailurePage(message: message), KycSignatureUnsupportedFailure() => const KycSignatureUnsupportedPage(), - KycUnsupportedStepFailure(:final stepName) => KycFailurePage( - message: S.of(context).kycUnsupportedStepDescription(stepName?.value ?? '-'), - ), + KycUnsupportedStepFailure() => const KycUnsupportedStepPage(), KycAccountMergeRequested() => const KycAccountMergePage(), KycMergeProcessing() => const KycMergeProcessingPage(), KycManualReview() => const KycManualReviewPage(), diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart index ecefde20..a077b481 100644 --- a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -6,13 +6,12 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/utils/swiss_payment_text.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; -import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -46,9 +45,9 @@ class KycPersonalDataPage extends StatelessWidget { return const _PersonalDataMissingUserDataPage(); } if (userData.kycData.accountType != KycAccountType.personal) { - return KycFailurePage( - message: S.of(context).kycUnsupportedStepDescription(KycStepName.personalData.value), - ); + // Same answer the app gives for any step it cannot render: an actionable handoff rather than a + // dead end, and no internal step identifier surfaced to the user. + return const KycUnsupportedStepPage(); } return BlocProvider( diff --git a/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart new file mode 100644 index 00000000..e3a488ba --- /dev/null +++ b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/setup/routing/routes/support_routes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +/// Shown when the API asks for a KYC step this app has no screen for. +/// +/// The app renders a subset of the step names the API can return, so this is reachable whenever +/// verification routes an account down a branch the app does not implement. It used to render the +/// generic failure page — no actions, and the raw wire identifier of the step printed into the +/// message — which left the user with nothing to do and nothing to tell support. +/// +/// Deliberately does not name the step: the identifier is an internal enum value, not something a +/// user can act on. Offering a retry matters because the state is not always terminal — the API can +/// move the account on by itself (an internal review completing, a step expiring), and the retry +/// re-reads it rather than stranding the user on a screen that will never change on its own. +class KycUnsupportedStepPage extends StatelessWidget { + const KycUnsupportedStepPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).kyc)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 8.0, + children: [ + Text( + S.of(context).kycUnsupportedStepTitle, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + Text( + S.of(context).kycUnsupportedStepDescription, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + AppFilledButton( + onPressed: () => context.read().checkKyc(), + label: S.of(context).refresh, + ), + AppTextButton( + onPressed: () => context.pushNamed(SupportRoutes.support), + label: S.of(context).kycUnsupportedStepContactSupport, + ), + ], + ), + ), + ), + ); + } +} diff --git a/test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart b/test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart new file mode 100644 index 00000000..6c2ef9fb --- /dev/null +++ b/test/goldens/screens/kyc/kyc_unsupported_step_golden_test.dart @@ -0,0 +1,33 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + late _MockKycCubit kycCubit; + + setUp(() { + kycCubit = _MockKycCubit(); + when(() => kycCubit.state).thenReturn(const KycInitial()); + }); + + group('$KycUnsupportedStepPage', () { + goldenTest( + 'actionable handoff for a step the app cannot render', + fileName: 'kyc_unsupported_step_page_default', + constraints: phoneConstraints, + builder: () => wrapForGolden( + BlocProvider.value( + value: kycCubit, + child: const KycUnsupportedStepPage(), + ), + ), + ); + }); +} diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart index 46ff668c..cf7dc572 100644 --- a/test/screens/kyc/kyc_page_manager_test.dart +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; @@ -270,19 +271,22 @@ void main() { }, ); - // The KycUnsupportedStepFailure arm renders a KycFailurePage with the - // unsupported step name — a state no page test drives directly. + // The KycUnsupportedStepFailure arm renders the actionable handoff, never the generic failure page + // (which carries no actions) and never the raw wire identifier of the step. testWidgets( - 'KycViewManager renders KycFailurePage for KycUnsupportedStepFailure', + 'KycViewManager renders KycUnsupportedStepPage for KycUnsupportedStepFailure', (tester) async { final cubit = _MockKycCubit(); when(() => cubit.state).thenReturn( - const KycUnsupportedStepFailure(KycStepName.personalData), + const KycUnsupportedStepFailure(KycStepName.statutes), ); await tester.pumpApp(viewWithState(cubit)); - expect(find.byType(KycFailurePage), findsOneWidget); + expect(find.byType(KycUnsupportedStepPage), findsOneWidget); + expect(find.byType(KycFailurePage), findsNothing); + // the internal step name must not leak into the UI + expect(find.textContaining(KycStepName.statutes.value), findsNothing); }, ); 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 9093ec43..1726549f 100644 --- a/test/screens/kyc/steps/kyc_personal_data_page_test.dart +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -12,7 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; -import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; @@ -121,7 +121,7 @@ void main() { ); expect(find.byType(KycPersonalDataView), findsNothing); - expect(find.byType(KycFailurePage), findsOne); + expect(find.byType(KycUnsupportedStepPage), findsOne); }); } @@ -136,7 +136,7 @@ void main() { ); expect(find.byType(KycPersonalDataView), findsNothing); - expect(find.byType(KycFailurePage), findsNothing); + expect(find.byType(KycUnsupportedStepPage), findsNothing); await tester.tap(find.byType(FilledButton)); await tester.pump(); diff --git a/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart new file mode 100644 index 00000000..19cc3875 --- /dev/null +++ b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart @@ -0,0 +1,62 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; + +import '../../../helper/pump_app.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + late _MockKycCubit kycCubit; + + setUp(() { + kycCubit = _MockKycCubit(); + when(() => kycCubit.state).thenReturn(const KycInitial()); + when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value()); + }); + + Widget subject() => BlocProvider.value( + value: kycCubit, + child: const KycUnsupportedStepPage(), + ); + + group('$KycUnsupportedStepPage', () { + testWidgets('offers both a retry and a route to support', (tester) async { + await tester.pumpApp(subject()); + + expect(find.byType(AppFilledButton), findsOne); + expect(find.byType(AppTextButton), findsOne); + }); + + // The whole point of the page: the previous screen was a dead end with no actions at all. + testWidgets('the retry re-reads the KYC state', (tester) async { + await tester.pumpApp(subject()); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + + verify(() => kycCubit.checkKyc()).called(1); + }); + + // The step identifier is an internal enum value; surfacing it told the user nothing and was the + // defect this page replaces. + testWidgets('never renders an internal step identifier', (tester) async { + await tester.pumpApp(subject()); + + for (final name in KycStepName.values) { + expect( + find.textContaining(name.value), + findsNothing, + reason: 'the wire identifier ${name.value} must not reach the UI', + ); + } + }); + }); +} From d7c3384de1517049879dcef950b480403a16ed4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:47:08 +0200 Subject: [PATCH 09/12] test(goldens): regenerate baselines on the self-hosted runner --- .../macos/kyc_unsupported_step_page_default.png | Bin 0 -> 33041 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png new file mode 100644 index 0000000000000000000000000000000000000000..6d44caf6452d966a4f72add5e7fa01d5dfb8b933 GIT binary patch literal 33041 zcmeGEWmHvN+&7GFL;(o_kuDJhq(M3rRFv*+q`Mnbx~03jyGvR?y1OJc-5qDH>$#sZ z&c`#}G2U;tV_ZY8Jy>hcHP@W~|F0$i(vl(=s6?nR7!2dA4DvtvGxS@RW2mg;?D=Q)dD;Xq#!(cCAUq613cS_n_a8!PwMEvA1Q#Zs;|S0g&pqQ_Wt9?=SYay*k1|I zKYo1u|Hg+bxATaX2~DmS&-|W|iaNIX`=fkAIMmpm-@U*%L;Rj2k+^zu&?==wPG%TC zhe=*f-lC80Yv&&j&{lv;CwRfUMYp=O6}KZ#WcfB)Bf(NWK4Dh)-J?(aqy4ovIO-rSTg zd6tt$q%>x=(hH#+)gyoS_)+WG|Ia1pNUvT+FcbLue`(i#`G4>8zaReZg#7Pf02}Im zH^TpJg#Xl+agE9M~uOnt2Iq@#L=Q~F;|YL@4-R`zax8NI`Qy<=>9$mnFgB@3>eG3DEI0s>^744SBN z@5GdERS{y=>Ds5WUbvj@`%TwzHOE-*U1O-%+ni1=ak$dq z$7aqhF_Z_sBjesu0*ip)bfo{!mct)(sT97hMR#O@9Rh)9*9&*)o;GZ7SzCkbn-`4g zTyilt`JwrLUl0)mZf$MZodtx2^)&weEya-|)q7}*`I0|k$wwmP5?Nd=Q9RrIGqxsle6nuubkGm zcejoXL#3@T9sdG6(?s=6^=BI}5|=kO{orFcU;Z0+P6#oD=e-+^a@qSF##q*&F0oh` z?8f2Xx8ss~ta1OHe2=`rHm5c-PnBsq6Oj z*1iW-$*V@S*ukygdg9Ob0!a3|rXQkDCA({KBz(!JDJT$BDh+Ohqp=>TRD6@+J-F=> zqmj>hxiJv`L|7OR_Fdx4guJ;aj8KV;lJazKK`g-$%z9iDO-fE!4xI4pWa!Ni_|HC6 zVF~__^OwvkXI+2tC~rtF9|!C3nGY)NI1%bKBa=R_MvuIiIte-kI%`vipuRgb}qMNh*@`> zAga2+8#TI6=eDyK1v5-~ z6PMZUZn^CRnZBx!&?6#t^B*zhvts5-C7OtSOGK35^+1CrMHX3?(>ad2y}M&En139_ z!GzZHSt7cn#A3gXXL^QLdm^ElnR)n5z;I~s&S;^8Qhb13$HX8&Mz4EiB~zsD_FvDL z`#H%SGP>gI8F+FJkNeSzTf|4hg{3&6iLswl3PlOtSv-1`nK8;|@a@~{oS`HPVqVv{ z1eJ`3?`=+}m(~eA^0g(ip*>{HH#(;c?svpcDx+oi~t?k%4YsW3d$a`}a%zC2B$Hlvbx7;9hOxf1hU$^y>}} zuN)p9$Hb)XPxCt5dQa+^OrFJskxIR$9UDq+O|vHUG;ZmWLf@7z()Jn6$vHf4DHL}F1@J!fUhV4U?&e}^IG+xt z{r;f^1s%P;0=IDaW|y0zSPKp8kkkE5Z%z*n0v0Q?Ry?Qh~~U@ zLeYJbah$GHj9T@9Z^sN?a&SEJLOMl;Jw`@eo4-P<+uQcl>W}FP7R>!U+Ag$Lr^aR( zxI!tSHy5g5v{X|)>&S*+xs>r~$HCt}`1kS;#9xA10(DFEUhRUnX;s|dNyzxz{B^vL z)Aeo~rkl)pc^u%OnS;pEZ3Rsg{DT^G{<%`_BbjRWRYsGKHw+9=&`~=(M8dFeNil?l zh1twUQDPajK3{2he##9EVA^m#xtH3rqs*KiRX!mgq%aI6;qq90JzH&z0jpbEd8)+e z_!9o9tSbL4Z28v-_KW)!jkXD~ z&0p-MitE;-B*YoUk==2;u2kw83uO*>pFe*3q|-Ag@qQFdvJ%f6>jjDT(OUOQtroZG z#se6K`3x<;CfG>6p#(m*n@nk-?tu~tQ!Ld8t0CoC>y$CPJ-oY6ol=-PQoijWFE<%b z6;I`P;_Hh9F}H;3ogZ0DZIKrvS6S|w72m$Te*HR4VeT9}dqBWiPg|WbOOmzqhXW5u zp2Yj9}w za8^8eg!gV2?~=UruLAp)G#V~mn9n)N-lwn|rbrVe`cIWE(Ts6e&QQsBQ?2ed-@Kxr z_>dPY(V6RHcK=uv zuhk!w?SmEgTj={Yis$;OXeZ3O>W*;6_+oP4ZzWQ>)u20zn-yVwooj9|H;0Cx++tIW zCFK9>7Z&slJDe8JUv+VaE&BHA6>Ls_`S|hUmws{qn=0^pD1w55`m^q-$k2fIEZyl< zE9k9N?(av}9Jb~qOgO(ye}QgMUT!UfLUF#&`w|*P+KEoKgqc|qjY7AOYS;-rW6sQm z04CX&of2aWd(tp~E4~i-{p&HA&8TL(cL@#oc%kX;gC#`5@1PDnSIZr8mVWil^d}zQ ziFhvkgtCefS_xu7>k4STW0|!6!!q7|(;rXag74#V;L|unq4~yRhR_6|JbfyY!)~!C zDqmY%d|SM8m2_K&LBugqCwWcZF*GCv?(coFl1UU>slVd@jU1mzLop|Wj8~KBVCtWs zHSJrK7F>VghDc#REyJ7-7@Sz`Xpv>+{R6OCDI^|j* z^17$Ht8HR{bYsJS%klnaMKGW9@k>6wM?+dozh)ix=D%*nJ;YLDPL`;KHaFH#dbYR` zT)D1_%tmceFB~|feZj^RjlNddspyNQE4+P>qIx5pb}yK6dF zM1*~N$0i>d3)VIa#>e-s-&U@)uw<=VbiMEWD7OoY%l;bu??7Lgdm{%lH!CaHVA_Ag z#&YhO&LDgpLV)F^nB1AnOnh{5daT*vlD(-YZZ|i90ZnJQHH*ILU=p`XZK!*K?QXpF z?)u+w;VVy#AwyW+fi$KgAKyr|2-AF>Gu^=PsfsUS%TNl0bGF7Bb8U7#_wL{2M*OPs z0yTnt@S?>P|HqFHTRUvh2^Qa?h_S;1HecWmCL)f>W`oy*TBKR|?Ah72c3;+0=C?AB zm{O=)4#@?rpM%;iSs7f0sWV!2+N3-qU+PQgcisWsU-J!j`qvXhr(Ik8dRtFqDJG`r znaT0lrP&7Agu*zR^}gtwWS@HH9M2!|s@Z|K-?Q+G_?&|ePxG0!r>8yx-0N^g{vEV( z^%fUgfbqPI^JP_Iv-j6(OG}xE#vOm<5E2lixl|XZ#^REahG1uQYd^FJGqd)EqxcfApe|Pw;Mi3>fBq&ljOnNs*K`ec!1>H(y;{Wik5097TVH z(;ZBdKGookasRH(YH1Upsi|p9T_aNV$L$m8tS{I}JPyiwiDokuI)Bm-w#EvP9S#=! zoYC%unvpQ9hsH>+PN-92Nw&r@J%dS$ZNkCbB-{d5KU~WntE30pk1U<3ONVnll zqn*+NZyKRyq&J>*6JQ(LZ?sK!j)Z}w}dTiWIcA&iLg8Y6^= z>i%AL@Ycs@tOZAsT=0>Y7+P~|dT#|T-OBbxs9evpLU+pZ>T9Va(PK=M--qF?h%lFn z-I8*RtK;?M_4N>ZCarCCgA(=n;c8oI@pvw1YXg}36tvVZupD)let4yx#uiJZTt9mI zv#a2?6aXCL4P={>O{Xl=0Fv^sHA{OudXI=KXoCJmjLIW_ z+ha47?QZ_bRUb|zYby*k*ziZmKRdQz4I5@M`Q`R?nO&FYi?go0X0hN98RNM=%MSbgHQ+g%NT~}|5yEHOUA|dN^BRN6BH#lT4c&tM zI8*>I2bJk}p=3?=(C*zf9S9}Z2p!kq;Sdn2CM?@a4;Bq+eXtCf_vFm((w zvkWh`dNN5|93OWVai1_doYF)wn8xR5os-NQo$sju$h1YW6uVJ~?hD%O@;JP5x=Ejy zN<86Fp{Rm2=rx5(^up!3pV-fd`F=lzEwfpnFlsjZ^a~?f8C|D~<8Y@EaJ&2t_M&X?&GdK?f$by2N!369`fB@AUoWQ6hQ`(!JcxJougH1Wo zY)Ec4*Mg%|s?if&I8|mf^89Z+C%FT=XkpFHoOO@vV}HamLV*k&eYPfW<)*qF3dx z%xS`!D%J}Fo6*VHInyAJuG(Z;-mBXFO)%L7GNXFa<5Xl!h>CKN3j|VlAuW48+-Nn_ z2Ne5nZ9ZCXLrmw#CJJZ>K^E;^R$j2XbbtHiUqFl9=o|Ad4Bhuv8W%Y()MrNd93 z(wBa>L7#z5(YDzpysmW|Eoz|e@d0#4fXq=r(P=Hw`rx^5**dniUA+U4R>mzIfKF`Y zBdX#_tP+>VkF$)IibX6(O7!rJX9v`3Kk@f`9NQ*sWnQvqhSXnh*|DS5KNe3kV9rVNk7&jL8H5=q?E}%)5H(I!v!SD@+>i(q`+gO_?(#N>kat}%A4!o~VPA>418_uP# zr>9I`&d(ow*-5uhL!szgq3nU+++!r9w__HK4FLebMz2>JElBQ8SF5RFQg}mj+U{UN zY1UDpU=Ee@f)`Mg)HN9t3Z!R*F$RC1K7HEW*I<%B=jx61ii=Abkk8lrVH-^9S?Zs1 zdb8eo=G4=EMWNVtath30P^Igyq}P%m;dT)>$^1XqF|#K$NLHWzV?HZhMv5M#%tB2K z>qAjLcXnybX62WL2->qIiNspbuu@Y(Ar$N3tFkH&P0dB)l-k~hh*ASo42WU z$p-h<*D9T!XBJvKA3uLyy}QABLGw#+puP=LM+YFJ58_g*|D&=K0V*E-Wv^vCh4=gS?_mIW_%u16Y$x4Y;s`D8 zH9FELr=<;&qvX86BMYVf6S2WbKu-7i^Ci;o}n zJMBFnrhejA>khyg)YZLLYzYzzi|UEOdSswq4ACbKa&{q=&8;mX0G%;F^D&#LliV)d zySlwt@?2YJeE)AC7T-BIm@V|f9P6*D=0}enu?(76_TXD1N@>D4u> zMxE2sJsyX)oHNrE>C#ZvJD?WisW(&6Dir124*wFs04}AwOa$UfAUhh(HQ)r>p^je6ragVtxHr<@WAk3G5^+dWOFvIrenxqiWZ7|5=?^ zv`k8GUS&x6{Xe!quV{Z#aB$!OxJmZ_K(pY*ukTsyWVII5lcDa%|K4i0dSoiKI{x@! zG+yO1NYwSJsV&FPY0!REyQobD-m%$%JhJw7u`hCs1H1+S~Kd%}w{|pP^<6 zVIiTzjgZt?=k)Y+r?YK)-x$x0BC&*Za;|^D=gAH2E4AHq9&1$3NNgsRP1v))*IEiT zWWhIMno{rX1Rrdv-`L-57M?q!{eg>U5H^Fg+PPbQ^6BHpPDA3UD>Qr`uzNatdeCxz z2zUO=A3NK=*q$(j*o~BXs{5%PIj_fR=1pK$MaEdpka(qrTr9u5A%LFk6>eQ&F7pUp zeu02r(t7&&_ynQhGl`jxONcRC#eHFfClJoDQ92yVP(!p%SXnv28DL~%2JV6|Xbc4t zrqQjbd}p|TCzpfaTTiYhS=rk{v>CKCxy_22gZXSF_zaWGvB$alx$}7?RsO}$)P5l< z(!+NcHaR)d?d@GxfLyMRdovQW^RRJnPG-9Ch=^#xMg~~)!1~+ir@w%plqWFdU__izk80ZqqEY-fg^+hR?67;BSlVzY9u=l=Y%GyT6)s$IXq9?Mq;% zllMW(^k{CFuFh&%XCbiD}994V6ggFK=TcgeR z00J`&U}9i&{Tb1dndp87J~o=HzyR$#`f7iF-)JU>A6(61^@mx#)mkq8;`)p@n2b{s zK9^^VR#(6DqxM3B&2$D2CSB>E0ZEU&k-09#uA*%RONcPQK;Cl*3q#UWObnhhE@M$u zz3YL1%bESp{P;J}ZS`l-jR_micRvAPRX$H0BQobcz;Gy)?qp+7%b!85R_|zZ9g=`& z?YGcj%H<~PG4yH(u<6=|m%d@wr1*>)z9Gr{Bp#+k4yrCjb2+&~b=q5pGEk!iAEE=h z0;p67Fi`l@L=HTd*(*QxbL_3y>^EuNMcgq-k+GSM61ZNF|H>hUn630uI@vFIPN)4_ zA4snB>6>5%8n3ZRLe1PVMThnEDIx4PgtQD7-R2t)5P>cWWU|L#){Q1gylnbR#$t-g z$}3~|S?%9GIo%VKCfWqz!bp)mF{I%tX;Jf?x#!r5dy2<-1Km`tU!}tA36ROZ zZZ~?Y8HmSoAmY+0c>%@xUbX3k&dQ1?F_8MuNd-i?2McDW(}AWQn#z?AnBtQsnO}n@ zm8-rH!j`Xs@ClfWXlUh2kiZk86AC9VsB&$}66q zI`(p;SxJk+8w)6a8P+F?8OA}AtDyIkyA1F^%YDvaSAJGaMF|(!Jb}1E1WkYd8751XXl`$BKZ1>;+-ehT4T<(|^*6zBp&p#ADl25R+eT4N5C6(t2Ef*0;&{uCXbY3H_p zWNs|La&>ishB?%^w$|R&Ddh(&w~QjGJ}I}}HO(fw`Z}wl3Ztp=pgh?HMQ({gWAO!- zQ#_np@yMFC&bMPxn|d}{;MtAlS|Yiux2vWb+;gE91d8?3a68dTQG2zcn`yyLppSNL zFw8|sDQ^q>$i3~v!oy=EVl#WiW4E7Ebq4U#nBgE^t;Ry<88Ih>`gDQ6;}Rya#F3vp zFL>SJ;ns^T=*X-;9wtb%_AkFPtHrhlvDRXtJ344n`j{GLdaA^y@nV1N_Xk5eqbWuB ziEVF$a+8%ng~7!A592gBL3F~X%R$j>+F&3tbWB|kdN3&A!G?>)472x`vL(uOCrpxf zJrT^+YJXlVxPsc=)2lZ~qr_3}K0zCOrs)Apk>HS^boH4EW;V9(+1Z~0j~>urG^WPs zMIFUzwP&gq-++i!?l~k0@s@Qu#Sgimdn@CmrLr7Hz$1bH_x%E|j*(HC!wr2LFR=ox z8Y1lHBQIvK7EmaDy%EEMz1P;pVr)qIe1Cg2an7VzL=*TzW@R9evybCZnnOa1-CpJ4 zAxe=}B`Uk=G>!W?IiGhDx2^7A5uBNs`Td!R0>Ep`lBwKiww0_zH`xY;YB*p-5O_b` zpCK*PYHb62r`-Gm=_MTPB)eZpcjcDbXrh7}_L7H($jdL}_s{yuaDG>Z<{GtlEzwM) zkyj{AH;OZ#^O@YB66$tM%F2ZqhY->0xTmop+hK48EAz3b?<3+;4k@=2HHEjB4{RmV8|-66#;VugNx zj(7n7c!+G>oUJ_aL24(MD;*1idzpT69;cVHRR`Y0oEGVpsVPQd1-Lew_mXQB zry3Vg1q#JK-{pp`EPpVh*JydJR=fB8zK2Ofw~#v>ymheyt7)mkQyf-*N*TxL6E z0llw5kl_g)QsIbFIEtwDq5#F*s)I131U7+3XPl7-(}(WC};a zmMy(Ue#^DCmA#{%!j%>REIpkZ1PDoqX-&F?1^HgqS+X&N?(k9*LuDF8qzox(`R_M)dYjc=f?Ej zB?k?;F$2R`#cI{>BfBw18r-Rifaoz9P5TPy&ydeNT?!cKpJ=++Nqy6uhkVeMzYxi7 zJ7$mz38sAse`Q>wF_Q7A>Rc|@xiUw&RtG1A&p9x;@jOlV#-R_*#z}cK5os=gU0b~< zZZy|4HUJMdot$94PZxSp?gY2_o4_Iw zFW|I=0%@qi$e2ROv(?M+bqppZ%m0~O9=z6XTK3E&b^_?&!(j5n%j{VxDk>OEOw2YJ zS@CnRS}XP&v3u{!^FR%U+_8b;*k@(qYTBc2b% zwzIjXz~NtMa3oJ)H%CWA9QaTYhN`$OljV;JdTTqd6A!0;*7r4$2R)#`01XsWtucyO zvEE^IxP2TKAD^xiwof!iKtz=Gtv3}52Pbl$MSm!qlu4^4A}6`kO2#F(DC~lQqO7LG z`z=mS2as58{{2a>97<5=Kit=Nsan1FJ%PO-bHy^$>3;ReGamBpK>eVRqOWUNM_C6N z0&lJhN<>|bR^Pah@pZ;mG7|7x@LgjP1d}Z^{H`%rb=JC>t(MM{{ve*fhXI@}VNl5T zZ}@(LTKLYLUcX;;d2g=P>0+N0GNfltHpEW)A5@{-Lg%fIXPe+l*uhe()Rf2wB+vj1 z3b+U5%a^e7^7203Q*uu>q=*tHATo%ktGhK!-Ti~VgM>hkA!r*KA_vGs59C6&#?$$K zRWTqUdX*V0AbI=xwhs+yU9o5x(8@G%SK|%kw#1 zVv)DEgJ6pUpUbUs*kT2}N=2HS-#Gv%??hiPg&w!Y$Zh1K;`;&#P`O4I7ci>NAWfjz z>=FW4zTq*^oBPYX!*IPgc6TCOU0p0(T%Wi&f@*VDhXW))9U$lPag%!xSl$4Xkbo7f zO1>65nr(gC`4xB)Xi&d^6g6)1%rNklUtKsZtpJS8<#GIikFWXHuU~1^Qs=ufIzXNE z#US)fr~JIq)gKEA4@z*b&Du=mroX>G^f~|>aBb)j)>2_+GhRfk-kI#V43JwsI0)NQ z2uA8-svepnFiV=LEQG5v_ucb3G~nzeCevzo6X~Gp$1PkMgf? z*-R&pp4snYD1})7-`Fs5l3`@X7|Y`}A3O^`Xx&lJtO2b(Q#X1Wg8dw3vp)s9Lq9jx z6eGYuiP1d^)K{+cpzKqQ5drYL?BO@Xz?u}h#F!%&2+w@NyB(_ubvKV`al6-+&CuwCLgj#OP00x%rBbQE?}(!ErCRrdTaqZfZYIegY}BRK4px^_6Sj z_zmyb8ZvnK2R7^N0H>9In?V)7vqb`M?59ETs3vo|#cqe+vmf#Cgh@!xet_CSC6)Bu zo|psDz-|T3G5;p>ztdP7sg*E!dl4qkvl`17geBy*|G^CvDBS+R!9p!=S5+ySLwRL~ zR={bD+2P<9`E*N~LHo1QbEK z=%<%g_=V>~WXr$XByTK`br1puTs0YHephEFNM7t`o{vE>v+FZcIOi9%L2b3gEh@yeH?4;rskqHMZ0^r6tTzTRUwk znhJOcV>48ls4u?oKC>SasP^3HdYzb_e(G})`%)~1{<1|n-R`14g}?DwL&@a{(vuAB zwx6OjC=|=v8_!_OeVLwI*-;?n;46f5XDG;mH4-}nlw{!H0{M*gR=wgikntdM6>VeS z^G%Jpz{O#U3W%s)7lG{zZV?K!HmKVE?Th*mF0((xUt~%2UoKp0Lg6cj%EMXyPnr`} z1_;*Ql$#;Uan{1V>0B(}$N^s^s!w0*=c)W-HNXV%7*&sZdrmF+C0*ch#*Fm?o&~R_ zTeW|urMoN?B--gQ!~Tl{USHFCma13cc{1D|Ys$m4FH+pgN~d68U4d-6OkEOh*gpTw z7Oo+T!jofdx-NQfC6L%gJKy)6o`4DI1tFtP_oK;7{*le5uD~VM5{+F0Ko#V+zx=o z&-M$YKGlCq(bR-&ogKwe$SF787aJ=PHcaAj7R_%k03j_6*Yli43DMq(euw=EwLdv3FOiMUeq7SH^qb~y8AVz^lVlUE0vx6!ZDz1(2uxpG9Dd{P(td)bfse} z$Ty_mu1ACr3(COf(sXbjDap7c+Fk*=eyv;T1w_Ts?p5g_oeda1aFfW0920X1wW=6! zXB3Cqe{Z3mltlFN2QwIi=$F(5AbqrXqB`l$Wv`s5ebEz720S5@m6PQkWyKm&!6488 z@-im#WcZAlc)(MGcyz!TwPYZq+l}b;WV`2q`vHj-yGJVJImiS>k%=g+Gi%@;Rqb5_ zD;|hhoLnB^09gWL7^G`vRAO!aqsvfs1aRNM@^9To`QQeW9^{n38KMWtI0k6weG{f8 z7eVHGE~43Wpla!;v}6F*OEdA{mdgKMn&xErgD)-PIS}YU#|i{uBLuxr=4Q_zmBMVc zNx|Pv-d235!CkDSUKK%?-6L=5PbCVKiB3bdv1O)X$PvkJjl zZi4`Y{_LbVFEk9`)zO&CA_@BqE_{sL=bvC?x_ZJ3q?7m!HY@+}79i5-jzs9I){2-w z?*j-1ChkdsRN~LG`v7P41`!>qy;|__{{?NpSAw8TBB$k3AO<)ck1N3iL1bp6z#N&v zd$?gKGInnT9&ey|9OQ8vl#3+@f`h$0srS}bd7t~?G0l2WIkGFQC zDy{r-q}yt8rpoU6!5`#aXnSJ)a&wP!=<1s`{>Sx!0!N!1ctk1WmP^gh#FvA(i~cOo z)@7Nu8h1~N=4$^XUWY=P6UzHJzWp?Mr&FMqpHUuGQlJ)1s|Z=Bc?!7#0JQpQ@;A`c zs!rDG#tPuBhmuOsSHn-1DM0YWtF=`yMc~W_IB4=^w53&*`I-{jO8qrdkhe1i^sa;w z6Z2kgtUBi%yzW~8(Y1;K`I?yh`UjSYav>ohtL0zs_7^TbEH}P)a&lgolFxomr&tz2 z)-&7ieA0Z{8~F9f(H(XQontji?^K(as(~At>A=7XC&*XLW;Ga02?D0E5viB~N$Nzw z{mJbgpxDy?{fm{ZQv|dCZIEB^4-9Mv(Q0k2Mm%6Be}6lcqqqGppw4Zbc6%b<3qVIV zyRI&fIz@!h%9qLJ#K+LSIU8}DfveP7_HyE(2M8YDYo$DV9YX_=vZI!Vh5`%y{uqp% zsUk>@DL3k60eQZI-6f==~?T8+8L-zA&oALDwG z&jRY|l>cWU8kXKEH%ldtcbP^!ga(F=^2Ge*5(vVjE4bjIQz$}tJz*o@vdiUSw}U!I z#Bq3X(gQ3zE)a*zkxW7PmbR*EX12QbTaHW*B>QBex!q%vQ|xbI+~nG{LHg&_C;2-2 zIfVj6d-yS~vQj}_O<0RUEm#!61q!r!Ku(3issPnS8RIrWED==0mEHU`QEQj~Bw%30 z`386f%UL+P%}q#byFz*BEEkH&+<_ieHn#VW+wQW*15)2-BmDtlM{lf6fSue8^sh3@ zi)ZTr{KkE3FCLCW@)tl62i8ETI>Y+*HWDzg`jR=T6`qaNzDjAhOCg-Nk*%P8paWQx zUZT-?hXa;o6z-`xJs$Grim@^E_)`3>>Me7UZQZ5q;Qae1Q=d}OI;d&_jaQ6g$GR`BMWl^ssKDeV2VOc=;ELo4n6{>BpG z62LJu8Y#*jmrCUzJWA9qkt_n=P_h-)-t}$J1z_avl_F7 z(7Zevz>x^RYx>~lU>?qU;i%ekY)O)uKT)FZC!to8aa+2cCYuj~g<_DD+0Q)#%85^b zl}>X_ITa*D;nFGVfGq^sQqcDm&75R$#LEmE0sC=lV(RN!`I*-Fz>|*mx%YXdmSI1H zebYgc=5pQVOS(P_pUo;+&MMxX1i4};&J{&(9y)5EZ+*Y|rbxL#nL8xCP&c+D&;}G~ zP?T|aYNXUgXu*3M&ewB=Jm2_9kUt5&b*C2>js8&DB1ypQMnkea5q%9Gqly=pn3b)C zFV%8_Wge*Me$xREOilI9+vtC%`JofVriOeT$B$d%VugnU?#B3<0PkZBUC;lBZz-qFy+l^-CJLeqB454Jap^c6Q#_CfKMMHOc7n{#P!)kAD zADf`2;!p&>zdv}1#xB6LKg2}A0^|U`pBN7{PJn8jQg{Qg4oY5-z0Q|#G;^M*G96(3|2P1_xD*%5U-Ax4WFQ{gZ_p6?*y)6V&AeFy5#NQw5VDU-L zjNuGYL-Qm@e(SG^j~czb zr}EFnAG7bh1NVO(HZ(5azfwmk|F01+;m3BLr7O^w!X;uN2E{2*O?dJFDhqs{|b?b@-oyYgNOF z4CWvMb!l3@q4v#_w)vW8<&+Q~_8w3p$Z&Py4T~C;)8Di84-NGN2HJdsJu#D3bHqk6 z_fETs0pULY*fpEnp1!^t<)`}G;tuk$uN8|`prZyOS)WXSbm5T`-UnGjz1{O$vdp4nU;# zU}YBu3P0o!(kc}O+#@2)-tgFZ0s{@P3!p`o zG*3aUvz?P(CjH6S#75qqKfS{<=wN(MJqn-Nxx&KQ6v z*wqZ|(wfGv3o8>q$`EK{%ud6we=Mx+g-WH^cw{A2^)^Q&pmsi}^juXZvg;957z_mN z&Qx~v+(;(i-$znQW$K@77>y4fRfdGOklc2r{U|Xp&NrW~B7}}EMdnJ4R7kk;c^*CD zzdrQ>IRBiS3k8<9whSmkdM9n(_wsCq6R1^9vg}1N zV|aagRTBy-bHHLus5V0ctm1Tg#~U1ZiE;**wKJx84iXI^uX2yaZn7{{0C!0?@Jqq` zWZsG;NGM)*ut3Hz>{!r%2RHPG4`p8#ME%u%siQLvnK^bAYqhu%7qf+|}i ze|@9Z{u<)iUcS#n!0``YzJ^pS$r7{KK?hQ@J?9)=D?k&5V7tPR(2xg&;Z5VJwMalwF$xQW49V6Mr( zAIoKp#oh^aQKkOylH7Tj7E5^w0HGGPwIkXOHc@Gl~WGom}`7ITdqgkF%4&@+fA3A)p^WS;}; zVkFyDD*J`uHnn@7_AoSC@TW*XjuA}+vkB#sqRbPU!jdt`C$619E64y@eip!^^;JLr zq*!kb?v_d_lF!0XEkM||oo5FwFK}XHuhga-CUc)BGd9qUJ!Myz{3zSi7PT%ScRgKT z^?J<_-DFFRVecrCdTS<^33SOg<4uFJeDR##;WosP9CzyZI%_m=#{OfXV)6JQ^%oQb zB}c~>46-l4(XGf0ez!}M=wh70poxmg=)t|6IZSxoB@zszyn8_G$9jF6onEURDpeh) z#&W}%YO!qQXT^V`2|;!{>VcD;!RPks_-&UX6m9~MbxKxI*XmLg+f76mhz*CY9LeXK z70=H-h0&_1>Fr&I$m+ay2ahJaw3n13YL^c$5H~bar-!rrQ+>;BZng|qRJyg{q{DTo zE^}VGWi=b{u%{=AehjG9q-&)vk}E{Lxx5opQr}XG7mrL}H?vymCsnK2`wb4dl`WTc zSqYRe4b(*x7Ddw;2B_B5^qu;KVjP;*dkWE<;cvp>7c87_tMWB5I3@~pW>4eexLuzC z88K-irvF-TLo^JFJ=Oq^R$iK$I+eazX&bCh#KNhf4w+3vo>~!LpAW8Qw^+SYJ2!)U zzr6GeB!bIfVC9GM+Mg~A!izt~Ce-xV5zYPkn_j-U+@t72N$5opeJU>oX}d9un*I)? zjcKK0?@74pFlQFPJ|ozZ>nJ|BDX9Z6HdDo&->5M%PDZiGvChg^xTX zkGsbz3?y?Tbh5y7FJw_mD%eZc8Z)Q03nXjnqdw|iJ5!e1>KgzEXA9)hjT&d{)X8lw z<$cew)NkN*q;;V1WjJIPq#{ zubh8KketVN109P{(#G@Uqcv)9$}EWR>t1AbE^9mwax&%`Xb1jbVIm*~2hP;8x5(|y zG!rLbl6Mubn_?fHGyMGd)B1GU+M8PAO;&Hu`B>-N4I|WNK&lb|nPAW*wA(VeAmlwe zrhP5Hu??{6-UI_oy2xl19&kp?0S;KH0k)P}QkZbcel|o1yfXajGq?{xULfm0!I7_S z3{VCIP#o22Ekyvf2HFGg63z+}YCJlib5|?i9PjRYGcxIH?bwXSly$D8f&6<%SaQn>?nHN)A27E&N^K_UUgNKfbkfx$z_u z|J`Z+WsK}M`4?0YcQ_NKlRrVIlR(fI74jngeO=48m#inRQrHLtOd3>pc8@+*5T?iN z$LrYH*N7@re&U{tlp(emI;x>CQS6no7tSs=3kN>Xbb~oEkOn>x_8-%z%#yqjOVqm# zo`HSeo+!|r{`CQfNZfs@3hm&mAB)A`1EN})3Xrh2zAi!o621r&Kgw4pr1ZT%5q>W* zF=$8X2>Uhh`~7yIDesP4z~E*Z;{4H}xACZ>I8+E?n}ObP0g@a zwYX4GtDL~QTHGeLJGL_c(q({(f+ zru5{LlxMuWjjD4`-+p~QIySblwUuW(Tf1}vb9Q!)6WnzD_wV2D&d$!|JFbSw#y}(2 z^>oZQ@w+3wFfZBL1ejg8BytH)f?6-YU)$-QJ$l$C!=i1o*n zVA8uM_EwcwRi>cfJ2+hK`gW^4kXuUnWa7 zU1N@;n*W8%qyK*8%J0kwAoTo(e(_2hB`LZuTiirdmg@zx)t^HXOR}J#u%1}02XY~y zo8>dk#ga*9Q}sG4Y{R}JbJaPBK9#RgPo6UeXXL~bbEqDpUlH?L9ZJ{ths*6bWqEy2 z^HPDoi!_z{;DOLCm6~5BmR`_ux9{qmjM{^r>K@FJDWb5`#O|Sb{chuLV(C;Br>?H` zm1vesw~VT)Dv_j*AMu^DiLeB{ntAmaTKj}S}tRX#`IqjUcr**ild*w|w zx-#LYybk;AKb>vSEu!9aa%$bnqWBLfk$MnyZ$ytNd%B#sDDu3QhMgaIQL-i@Eyd5R!$z=e9lRE zDPLD%Zv0hy={6i|up85~x~l7TaYq8Kd2CP2_8H8}T#si*{Rr6vTef0c3StmO`)B!1JB#Hv5uWxU19^O*P@n9XC-$3wpieqQUZ~{2L6{*I{Jwn z?6T2gT)Op)UzMAKU+e4ZT{vZ~s?mP6q=NGgR)=L#F)>sCn~H)vj&8G?W=|(_W;-rE zNFN`bMr34UVi3)N5Zddn5n15j;DTBn410oB(mbB`KiS+j=+kdSl$^y>RTJjXLFC8< zzFTiAtD_SxLo~7Ia^ViIRUG!`SX99!GWPM=XZ@-*LoeFzec=_eG_@qI1}t|xW5G;hc}QW#OF zFu>Q9a-mzf4V^g`7wXNhW%C0+J{I&+hGLaQN$8;s9++#}r{?1roF8`Q-$3Ib<#JLE z3=WLoxUW{RD5O)PvcLNrJYJMt3POWljOWfC}`4Y zbbbCmFEMY(CChYyy@trrYG=VS?X*MPerGHqyFV*DGd22l_Q@a14b#fSDB^aIaHWCX zPGI0ZX9V;QXmx#6RpgOKRD~N*xhLk`bKW59B!T0`cnMi-p2I+WU^OyJ(54X^bI2xa z6e!kAm@Jc9$z7id9?4VWApo^W)7WP{O@}hO{siBoSt>E=g}vHrpMvSXx#Qzw?>V-C zZ`nPZ&VBML9db+S0#13Wf(2DoZ0R<(?OFKQfI@x+E5qiC@jcfY{zY4N&7Ey!4s&x0 zx!j>BDsh+cc71jOvBeA9jjteg7&AIL+P9!|ZfUoY(FIdlaLv`@8E5*P#{y7B?DA7oAbWWOrg5 zR~#6YvWXcU9-b3z9U3^E|56HPv^dcU`}g_%-oFSJ;TYaLxgOM>)#c^y4(4;qpn@6g zZTe|6Ug*wkgrK0I^|X2UjkGii?k|Xajvf5)9pS)PztrBIL2ZVk7LOre?RT3nE@L5! z+HndY_akBs3n9NWZ9c&jZt(;zgtKj9rbBMmeG&lrG~l21&{dSqjG+4N@GUC5<{(o%tEQDuThsXeP8gvJ?)LJ#n|AXaK&xc2ljv3bkXkGg}(GC&Ni0_N!e=`v0`|mR)fL-MVPUAi*7ih9JQu zxHJ&l-Q8V-I|NOz5FA2)5Zv9ZvEc3=+}-UI@80MBfjh=I_l~hY_UhHEt7c7ko~k+L zdKJ6pM^{8sq{}vu8~nqL@lz_dA>f^EhXGN{RvSpyy>TKUBHSb;Bfr;hr?y$p&1Wkm zdRA4Yt2`M^2Yx<-m`|tsmy)4EWPkBeJi0nFxY{QSULD+W9a)aQegTi%5h1h6S;w*|-)6B2jG!N@@%gjIdV3KTgz62zF!8vBy*nbRs@Ocvkvfz0d@&J( zyCQwyKsdfyt6)TXiyai+FHHyvmPgb|EK^+r;C?~;d|eI%0oD)^$*1AqHZd`Ist%-5 z%>$h)K*63187XsFuMy(j@Ut4brB9-!l!L-QJHLqVzqwSankc9znCPv?cpC&9FBu01 zZjy$ExU8&yQBlMleZ|)VPF2LABcyrLI>ys)s4$!iOZPUis;)G9^C#E_93f3#eC z4QXBeBu93!A@g*M{^sk~E5}v6>2jMCg?KHchB^?6&UOnJeBbP$LA108tnaK?>iMwg zma>KxHf~X(*eIFp^pAf;RYy${v?V=`@+-bbHd$t_VS0NH9*(>~d#<-o#8g z9Hip$5?Pb=Qgkz?KGOnGk4~=)gJ5(=<-%cNB3CtDGV6iT+|De=`L;wd zOYpNK!cRgX2T+;OL@@sCoATem!Ti!cS-Ijt{r%yRa*ZE1KC5+oztm1i)~(*L?ir)x z=O@Xtu&{S<7;zT}5C!-gD?7Wiree&K7aQuTLV!xEG||O_>^+M`;spH|FSgNdCNx`3 zb3-;-GeQl1o0ODvWbc|FlmY&M1s%P#f>w{-{O`1r_wti*a0BD`$i^1rnaUlvSTd7g zDISoVlw^@rlA4s1H(22iQ8ypwK73L0--GFb!JZQz9Ka1U(CoU44A~&5*ffp}=g5>* zYrUjB`Fm%WYmuBGxVfnR;my035f)S&uR^rl-~m-H^_ly$WtwE>>!Yk zki>t(95IS|s$csOVrB=Ajt<58PidPz)0Vu(?!@XpQ*A=qjtUx;aTPVkVhhjY$%6w>9|X9so_h6w|h?6+Qn-dTt&*-fC_hsq6WGIKel zm*CE%TQlfk1dVKGGf@++TzQ*R6h; z&#pugLW9wV!h-ua{&y|-9_%A)K0qNP~pLybq%-3}{xd|k(JFh56+jjU0( z9GxqAGxHE%2=Iri12szl;I{wm0MNjz^O|$mJ$BWYg(EoS4R^ob z0$*f+w90k%fi27Jv7D76ng~xor@DJe?XO|cC@3g`tBJwcHO{Y8H5G@e9=AlbzgN&z~~B-(G}1 zdGt?hq_0ulbpCWlT$52%vBCR;Z)@*4t~}}C{BZZGr+rMS-jz|KnVw5SN6-;mk)Rnv z;Q)YSd##7b;v_cji6z7)C(}q|NpjVnZ_yXs-lJ|WXS6FO*h2q&Y8UN2U8r;qTU*(t zXu2Ns9PNoQ_&g)AU5@?O`mU!$$B%WtYU)3b9_YDpoA(HL|rJPV#7V2Gf-%)5oP*T$n!D=oXImJ9J2ymBa_Of^h5#AdNv=?5q?YZKR!_s z5ZZs-y$JXN2A`D!j~Y{L^QdKOrqyIxL=F{be>Wi*FqiqKe3Dk^%cn8*4-!9|IH)C_ zc++H|%43_wgE%X_A>e#-j}K!1p^%v+PZTht>9*(m2R=}7T-#GH@n9qpipnVo6?1iP zSn=xBtN-x2xtpc+IiJT>xu+HG9U7YAPAiwueDMD?di9m@?<1erTJF=N>95`Eyyj3H zH=pD=X7w|9O5v3B9T>>=N!;f)`DDqgA5WA@#-x6BTXL-NL$rdzi4Co45zd#4!g6}= z8`fBrtWN1AeX6-uRqpwMS*KjN6b+F!`y1)NyF}7@+#KwyK_+1n^#I^*-It*LAqM}FSsC_yvY7ax-8e; zD#q=?rz(@DOCXy?+RgSSllSJ}Cw0*9&R(nJR|Yp@2R1HA6hFIl>?8DZ2UF@)Nef6% zfh{8~s zw2&j)$-;Rj044Nyt8JdLTmT&d&NL)ET-u%Yrh054JS?E72pQA~&@4@6J6{v!-Bg}! zkIAb}bT#$9ba{>Tu)xNN2T;ii;`HsKheldkmV!__T9WIQrlyp(yz!tL0LPvX^xE3D zQ*&@ees4RkzCbXNKkk*xW<_HS`;%|LwDn1AH;&yR>w+Uz^`iHiX|^d914FSr{s6^y z6`Ht4CH0DX_4eLTj3g62dyr7z%pj$!OI#f1HT+_DH%k1pZL(-r>8Maoui5+Gm4khW zlGq_7J!;emu0}=Czc@BT#EFD-2JZ|O78a$I-VD%zLbRm$1rw0m+4cmY^G+=(_ss*_ zA+kkI-w>l;YHBK4qlxR$&KR%|p_u^I0}FF2LR!x7(7Nhy$l*Rxj$EAH>;M3(iG@LoXHXr@NywU$aA}u51 zaHv~8>4*Xx!WfVa$rCOGNpr-+JDFC4Kfj`7k4%i3(cQ2|io|knJ)_wDt|qZ>qLP9_ z@+z1W=?8w)e?_gE^YNvh36JF%UtJFkms)OvGC3u+DlRy*ThIf3{v@QkQdpRuERnM_ zNY3JC6a&cS$H787*mW81&7sh&mVfk*#OHcG_jnE8E%#H{$A|BUo`Z%1U4OpTb68kd zsk6j}H1OP9?uG@0;TVl=(Esi474*8Jw|>-GU@|MDECL&OD_{f0qN&6n0PeNS$%cDq+AKOT#PPI zP$<8an1k1x31+*GO&i*?YY`Rj^pyttsRQ1cWTmG{(~T-R5_xhls@uU#v9`4=)V?WQ zplKV;K3U>z6Zh-j2*L>BrW@GkC`JafzVGxcw;S4Ql?~!!>)_LlYFR$sP~=OMaD9KXe3)Ci9E*+05N zj5(epD9NVj_*m1n+`2oqLVF{)ftHC>pfejwp=!`tPD$LnMUm(zCx3nA>=_n1_j z?dr+c*zwP&!&y%4nksX-tWVc}{CE!U>8Y-%iEyc67NcrlKnl8uM9L6MBAzGD8XFt8 zj*gbkvs*><7t(>7EEgOK3Q}cLVq=v#xmD+DvkJ0?)X!`PrKF?+ifpc7)X3gibHvyt zQ;+lW+E_R^u*JpZ>X3`!e|*7`TXP;+L#*JOz}}^}u}sRsG}}?Pej`I>JGcryigm|s;Vk# zqvNAPw|;tvBH&Bvb$6Sy#Bd{+UsHaBQvpH?l4XQziggT(GVckd_vc*gDLy_enel60;iId=8dL`Xqwzev3`s~0M=N)MQWad@=EHN>9^e5|n z8HK4qLgMO;Tq`paIZwow90n(i070&yxw!B8w?|6vdD1HhI^Q zTU#9>ENrZdV~XHps~>d`LBw2}g~+?tZ{;c8+Kz@qT_=7@%Aj2sSH&H#i5R^192+OKF{0DJwbnf}G&iuQj#~FkVVD~CWI$1!MNxQ$ z>X#ME&kVZLSYHcz>e%kbg@vO6H;Pr&xqegNb7@XqIZnAT5q^{Cmhu&O&yv=H5gJOf zYHbp{-mdjKGe;+a&n^P4J)h<4mHj1hE^*}jg0L_uG%N2-^X?Q@=H0yI9+wpzo@%N@ zhi60aREHASM{;T29(##-w_vqaXSoKM1#AGpxf*)Y@Ph4B9Ohu)wLYdT(^zT6>$O8wdtozSeD13aq>VPUz#tQ?H* z(R#*}*97!NEuE7Yp}(9}nWhb7KQcdopeq}nNT^=Zd-`^7^@|kC zixs`8YR&E(DUTinryHN5I;HZ}q3I6<=qej$`#UYtqE)VZ-C3Jzmvr0c$A*5~NPm6| zd<^i`E?xgz-gfFK?f@Zm;wRCq!?Dr*b`s1ZMbVz?bt41jx_-5S~@!w%(;wIo}scPolSN z-sm_ka`VX8cf5w9yFlxA85#Gh2WOjO5k|(V+L>0(JOPEWz^gMa=_Q!`)F0)u2!8k zT1!1VfZ5{8O%BP{8gx{55T3JzM zy3QZ!Lp=FhRq_?Pwlr*U*TQ_=lx#^)UOD_H%+Po(kK>qABdHB0P1-*;udHxT4P>K( z&)q6*@TK<53kwUoy?X#Z430yrUDAc2VTQ`UAlok>%R`r->*js>uhd5Ee8V?DC&c>| zOI8o6ceWR-C+-`;pG7wPLnYy`N`#2_(SM1kqh3V1=tz-cgFq6grS-uxe4$!&cx%WU z1Qg^D35qGqIcOgJjR#LVOxpj?o?v1M&2T{;W_9=_W|xJXLGZbeX4{13*Dt>f)oHP} zHuuAwGrf^KmbOOBo+R+t`90R=j`ne+U96)0qQ=qHfdxwPRpXU?4O@3?Yki_OdGq{P zv$J)ntu!r_wBLSokDF^weL=gcE}r4y&P$+lx{HKCz9l(tdE*27pCf5I6Pq0v_tbPY z6TQrWy}VE6F_K6;kgA&2N`&RFO?COHY@#{KH60cvg{cHB{cYJ0O=*BevMxp!s6A7++%-V_K+7WT z=jB}}lW<@l+3kThHQA)i1SUtndY`VGxU8-XNBM}YvTszM{3C3nXaO8R;Y7xpQYlNw z(?~BHSQSsLvYe>=rB?RET)GtG*$bC#``~Fr1Fd~u+6;xfD-Z}`^yGkpOl##nb`-`U zP+?QT&JGV68MO6Uxe&hw^`FZsM@5JG85lU?i*_I0I_ z9v?fQxjcK955=3e?vIWhB8&M&ZpW9k7;gN0d6pnG@D@a+k-sAtWftxyHj(j{Y7Le1 z@C|5*Me#B!B`$>_z*T+Oa8y5Sv&{WeFl4l_rD|WH@Y~nX?9A#QyoF(*y|QRTGsm@B zl~Ij|fTF90cB#_D&VrJ#tr99ZzqGQRyV#$loM`UdSP+3{nT4vrF-id%mYkH%4nRoKHoD)Z9*OZXRP80pnG_~*1C9oumXZBO4Q(wB){}5K{ z*dus1r5;;Ei5`6}?gblyCOPvIl2fPnRQ-4gdT$yblw)%UY(MkynCjXZBM z^@%QnAU@A~um(P~LvfyWsnQNHpClPBH=}P@YU)*P2h9c|IXyn|u5QACkVy_1*!(Fx zb}0AvQOjG@o3!&`Kqs*K&l=9kw#sH1RPjFI`u3Sj5KAPRUVf8K$Nu`Y$jsE#+F#M>NELsm@Dz)zA*SXcT4SI`M7q29>b%Awm!#k zSjYl_L2eZ7kIx9q4(-^&3w@siym@#RV6Sp?k^NGN%I95f6UTY~Y=Uq_zGlz!yYT%2JA#U!DSc4SCnd;AXW>VUL<7Gqt`j-aHlfM~?6#|eL z2o>gfLpV>+En^K$b0C;uKwa`MGfwh>LSm;P$q^y!$&509Ynfeu%c~Pn$MF$swof7- zlD_xl7&{&Dp3!hmx}UchZvI7YO)u?GJ&vvz_vV`G`AW@41$DZNjDT0%?z8O+lY~=B z2Zx!|P}%oJUK`2cis!|;Z?9=8f01yT6=C=IyF(Aot=6cH#&Np+R!3lUvL$bYnQJxizQRKvDWm-N|zqm``_J3 zSxL`ngKGlAs*W!g@WI5=s~V6W#tIz%3!_>L`k5X%a@^JHsVI=OK_CE59m}Lq$|s*B zyg-j`j?JdLD%Mj%x^NgV+SxIPl`>S`7%!5RzB@%wBq>b~3f*$pw6%&%3t>xW=(duK zfPVq$$l6@(l!>nWo~Sb>CnJ0&Zm!Y|*Ai)IVV@xGoExVvATc*pe#DdLxrZ})dbR5x z`dS`Wc;Iz@R1eY4%e>|URp0)>Es|Ncw)uJZ`*YWMFe=^I<< z4=N@Gvj}Z!D8CFpGcc;Y#1TSP0Sf?^)aXj2r8V|*zmN7 z6w=a`o`I;g1B><@l-VFTxsuyqof0Lr#b0+ngi!PWg4Zq(>@fO@BOzH*$i+m*gnV5p4hV>#P;G>YHbdz?qUhaM zG37H*0u1+07yKx%?DRHzvGm85V=np?T-$&eT#_r!nVQ_A~)^d>6GO054#(O-5PD)tM zAU-JSa%+}@XDwVMr_-yMlo?)}!<2gDi_E%HieQ{j9+;0?j^&EsLk=g>vUKq)f zj%~vXK8e#g0gbla<)7hg7iHRUF>n8+IN#dS&=5yJe?H9y4`&aa#|km^5E(j;hZMz$ z`oWWPPJ@hs1s*6btg=l<-=Y{Rn_3Z@jE3(Gd;A7$f5!b)=r=jRnEFA8-#^1Q3da)Q zBdq=&-M+daI3kOSp@Uq%cOLob{N8M&*uuLh=Y?O%_u+u1zhfY$=EdmU49LH1b0~21RV6 z`KgwFv-`%iJTng_ikKwWh=R!&J2qM3;%dDG74Gp&MKv#gZ_yJK7TGRD4=`GB$ zcRo~1F?qH>BR5-H-Z!g%0Wri-L?bkTj5>{;J)xLd_@d2}7v?9s%3c4WxJ z`%hZh@3Q-%V@|5t@H#R^y@mg|5}Zp{BJsYyjX`lYVWnr8?}tYAGipHUR>tCZjxT#? zuT0GFHe`!RE}f=qKlG7&ELbE8gV@P+EJ<~byZindz9yJR+c*O7eO3i!Rg{z zu>6IU#PJd`Q5PFB{=vy!Lak=EXJA23Y`mBBzFqxc)oymjP1R(_`jsQD5?+&FEd-p# zjf=UzT-xpnrJ(1)20NDJ;#a`}cwGzn0&iSwpHxHG5NcHuv$DDFUquogk_UU~DJy2Q z5#?*pwn27M0tXS79uNkab+s} zNB&aN2-U1Vl^Y9;YTDxtWAWCV=OVSPSKkfEes#@AU!Xf)UFW1NB~{IDh5#k~&{Jw+ z$*myQS6?bIiRNkE{-2s5ezXqjn`$#o3AXty_qXou?^@?DABi< zZQALBAjIjxEb4P_h|zJ+>-{{ruWhQx851HPX*y`+(Apjo*@8!mx!(jW+gT{%^x?v( z;2>-T50p;2{cv$_Cm9sg!$_BZ)Z9fZd*aJMKQoS%7Hc}}^oIH^ME>CW49>3|o4sh0 zFSMm10hTk-^7Mmmu)!UlE7nUV0FB7Ae~YWo^%s}~+}^&}Lw;Ro zlwf!~IBN45&4T?>Mfg-^VOH@?mc>Nm$NHE;K1&O|4wb#ITFrxpFR=HCNsGSTQj7$W zO?t-?7FYV$OY6jGaVd;<4Jw(phXqC0m$baHz%?elOA}4xu%Ys3s=)i2msFb)PvMJa zyosHz{v(}=OL_ZbpaFYuQ&cd&G3Ly)$Hm9>15ZHq>1Q?g&Izr{US7(#RL-?o!F3wv zg^?hX*_YLH`8!S>AAciCJl&B`OoSlM6Z-!^ZEpM`=&l!Tx>*YRL%%fkiO&nj`o+|i zkP4jv&{c)~<7FyQ5R~ z&#tf$Ddt4pcds#Tov2(};(`>_t4B$n2`pY8EEV!?$dRZX9J`qC-Sr(*{O*S5pnw9$ zYqD~aYP?e2_~o|cB^UsV#NN2a^w<2IAYq;QaoJ@EqjtjM?ew}jM8Hu_4b0GD4F&Q# zDl#FeZHSht!{2eIyV7#2*zuaxAUaj;9f@~VANeIH z4IMz7ax)o(bF1TNn;S!Re-(5RvKZ^1y1w5IR-+az#_#LWL-4PM#c5B<`cc$bE&@~o zH8-rr`P3ueHz9s+ntFW^i>$fj)ZoK+CUJZTdBc7k-UaIJH#zJn7J8BQvnS$j^w{$d=F3d~3vSN_U6o>;1sHS46*}b+QNo|A(8GtRcXYQJy2yU0xm7 zr2MYSA^80-X4quID|~AfPLf4QSi30*2YF|?-ON+<=I)nmBp&`=NDIIbTXSJdwH1sz z4czZ;R55JH|LFJOGUDCc3}=4Tdq(VVuDv(Hlg{<=^GO*GuOvzk?u~N?(9E7O@B4oc zq=UV%y~gL(TrTJ!zQVYWO=L(g!lyfw@4QbCtmy`hX9D>EpffpgB8fVD`e`5-~(nQvhA`6Ha17n2c8R z{ths9q^GN1eOB}ZF5xLI7-A@`fbf*IzooIkSVs7AIeG6}Z zmr!qBB?q7}_HEQn*~R3}sq)RMeAk1LS#N~C2QGhrBS!KSIyYrwFFHGXp3D!3bo%q& zdmJ0+H)u}gNLlB7W^Qlu;Mo;(G&!-*QSI>Fyi<8q@lh0eOX=&n>md8eDjHWkfL8Xi zy-;Jd8^l!TR&+hs=Lv1r?lFR;)g~7z={RRKRkkru05|zlNvE!rs5bXTd3(m4Nrh_d z^f|GxsV(2<6NHr=efm8Cxt@Oi)VNo%6ZSLT*1|zScSUN%AgYOkV#{LuhG}U{ea8GM!#NNJo30=bUuA83Qf);&!bLJsOqm*itBZk-l_jF#} zf+wCWpZK@?Os(5V_{{ttA9#^HhP@KT|NG2_2a>dlBw`eo`8Qx#5D5`k;bKAkFaHZR Cx|OT| literal 0 HcmV?d00001 From 46d4746eb9400cadb422142c52535794c5d4b501 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 15:04:24 -0300 Subject: [PATCH 10/12] fix(kyc): put the handoff page on the design system and in the responsive gate Review follow-ups on the unsupported-step slice. The title used textTheme.titleMedium, which RealUnitTextStyle.theme does not define - it silently fell back to the Material default instead of h2, and the body copy lacked the neutral500 the sibling status pages use. The regenerated baseline had locked that drift in. Register both new sticky-CTA surfaces in the responsive catalog and cover them across the full device x text-scale matrix. CONTRIBUTING makes an unregistered sticky-CTA surface a blocking finding, and the catalog self-test only validates listed rows, so nothing catches this automatically. The personal-data missing-payload surface was missing too. Reuse the existing contactSupport string rather than shipping a second key with identical text in both locales. Pin the support handoff under a hosted GoRouter: repointing it at a different route previously left the whole suite green. The matrix asserts the CTA's layout but cannot tap it, since pumpPage hosts pages without a router. --- assets/languages/strings_de.arb | 1 - assets/languages/strings_en.arb | 1 - .../personal_data/kyc_personal_data_page.dart | 5 +- .../subpages/kyc_unsupported_step_page.dart | 9 ++- test/helper/responsive_surface_catalog.dart | 14 ++++ .../steps/kyc_personal_data_page_test.dart | 4 +- ...c_status_pages_responsive_matrix_test.dart | 73 +++++++++++++++++++ .../kyc_unsupported_step_page_test.dart | 42 +++++++++++ 8 files changed, 140 insertions(+), 9 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 0d193aea..d92d48fe 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -134,7 +134,6 @@ "kycPersonalDataFailed": "Persönliche Daten konnten nicht gespeichert werden:\n${message}", "kycSignatureUnsupportedDescription": "Dieses Feature erfordert eine EIP-712-Signatur. Im Debug-Modus (Adresse + Signatur) ist dies technisch nicht möglich. Bitte verwenden Sie eine Software-Wallet oder BitBox, um RealUnit zu nutzen.", "kycSignatureUnsupportedTitle": "Signatur nicht verfügbar", - "kycUnsupportedStepContactSupport": "Support kontaktieren", "kycUnsupportedStepDescription": "Der nächste Schritt Ihrer Verifizierung kann in dieser App nicht abgeschlossen werden. Aktualisieren Sie, um zu prüfen, ob er weitergelaufen ist, oder wenden Sie sich an unser Support-Team, das dann übernimmt.", "kycUnsupportedStepTitle": "Dieser Schritt wird anderswo fortgesetzt", "label": "Bezeichnung", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 50fc2a56..c32239c4 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -134,7 +134,6 @@ "kycPersonalDataFailed": "Could not save your personal data:\n${message}", "kycSignatureUnsupportedDescription": "This feature requires an EIP-712 signature. The Debug mode (address + signature) cannot produce one. Please use a Software Wallet or a BitBox to use RealUnit.", "kycSignatureUnsupportedTitle": "Signature not available", - "kycUnsupportedStepContactSupport": "Contact support", "kycUnsupportedStepDescription": "The next step of your verification cannot be completed in this app. Refresh to check whether it has moved on, or contact our support team and they will take it from here.", "kycUnsupportedStepTitle": "This step continues elsewhere", "label": "Label", diff --git a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart index a077b481..f5994ba3 100644 --- a/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -40,8 +40,9 @@ class KycPersonalDataPage extends StatelessWidget { // its organization data and drop the org-only steps from its required set. final userData = initialUserData; if (userData == null) { - // Transient: the registration row carries no signed payload yet. Offer a retry rather than a - // terminal screen, mirroring KycLinkWalletPage's missing-payload surface. + // Transient: the registration row carries no signed payload yet. Gets the dedicated + // missing-payload surface, mirroring KycLinkWalletPage — distinct from the shared handoff an + // unsupported account type gets below. return const _PersonalDataMissingUserDataPage(); } if (userData.kycData.accountType != KycAccountType.personal) { diff --git a/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart index e3a488ba..675d8f72 100644 --- a/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart +++ b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/setup/routing/routes/support_routes.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; @@ -36,13 +37,15 @@ class KycUnsupportedStepPage extends StatelessWidget { children: [ Text( S.of(context).kycUnsupportedStepTitle, - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ), Text( S.of(context).kycUnsupportedStepDescription, - style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), ), ], ), @@ -53,7 +56,7 @@ class KycUnsupportedStepPage extends StatelessWidget { ), AppTextButton( onPressed: () => context.pushNamed(SupportRoutes.support), - label: S.of(context).kycUnsupportedStepContactSupport, + label: S.of(context).contactSupport, ), ], ), diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index f45b7dfd..5903b980 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -82,6 +82,20 @@ const kResponsiveSurfaceCatalog = [ 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', productionPath: 'lib/screens/kyc/subpages/kyc_pending_page.dart', ), + ResponsiveSurface( + id: 'kyc_unsupported_step_page', + description: 'KYC unsupported-step handoff page', + matrixTestPath: + 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/subpages/kyc_unsupported_step_page.dart', + ), + ResponsiveSurface( + id: 'kyc_personal_data_missing_user_data_page', + description: 'KYC personal-data missing-payload refresh page', + matrixTestPath: + 'test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart', + productionPath: 'lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart', + ), ResponsiveSurface( id: 'kyc_account_merge_page', description: 'KYC account merge page', 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 1726549f..720475d9 100644 --- a/test/screens/kyc/steps/kyc_personal_data_page_test.dart +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -125,8 +125,8 @@ void main() { }); } - // A missing payload is transient (the registration row has no signed payload yet), so it gets a - // refresh rather than the terminal screen an unsupported account type gets. + // A missing payload is transient (the registration row has no signed payload yet), so it gets + // its own refresh surface rather than the shared handoff an unsupported account type gets. testWidgets('offers a retry, not a dead end, when the payload is missing', (tester) async { await tester.pumpApp( BlocProvider.value( diff --git a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart index 8f42e609..a8dfcfdc 100644 --- a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart +++ b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart @@ -12,6 +12,9 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; +import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_completed_page.dart'; @@ -173,6 +176,76 @@ void main() { } }); + // Two live tap targets in one sticky block — the first of its kind in the KYC family — so both are + // asserted, not just the primary CTA. The DE copy is long enough that the 3.0 text-scale cells are + // the point of this group. + group('KycUnsupportedStepPage responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpPage( + tester, + cell, + BlocProvider.value( + value: cubit, + child: const KycUnsupportedStepPage(), + ), + ); + }, + reason: 'overflow on KycUnsupportedStepPage / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycUnsupportedStepPage), + reason: 'KycUnsupportedStepPage / ${cell.label}: retry not tappable', + ); + + // The support CTA's reachability is covered by the overflow assertion above — it cannot be + // tapped here because `pumpPage` hosts the page under `MaterialApp(home:)` with no router, + // and the CTA navigates. Its tap target and its route are pinned in the page test, which + // hosts a GoRouter. + expect(find.byType(AppTextButton), findsOne); + }); + }); + } + }); + + group('KycPersonalDataPage missing-payload responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpPage( + tester, + cell, + BlocProvider.value( + value: cubit, + // null payload → the defensive refresh surface + child: const KycPersonalDataPage(url: 'https://example.com'), + ), + ); + }, + reason: 'overflow on KycPersonalDataPage(missing payload) / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycPersonalDataPage), + reason: 'KycPersonalDataPage(missing payload) / ${cell.label}: CTA not tappable', + ); + }); + }); + } + }); + group('KycPendingPage responsive matrix (full device × textScale)', () { for (final cell in kFullResponsiveMatrix) { testWidgets(cell.id, (tester) async { diff --git a/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart index 19cc3875..aa29aaec 100644 --- a/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart +++ b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart @@ -3,6 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/setup/routing/routes/support_routes.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; @@ -16,10 +20,16 @@ class _MockKycCubit extends MockCubit implements KycCubit {} void main() { late _MockKycCubit kycCubit; + // Hosting a router is what makes the support handoff assertable: without one the CTA's + // `pushNamed` throws, so a test that only checks the button exists proves nothing about where it + // goes. Mirrors settings_contact_page_test. + late List pushedRoutes; + setUp(() { kycCubit = _MockKycCubit(); when(() => kycCubit.state).thenReturn(const KycInitial()); when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value()); + pushedRoutes = []; }); Widget subject() => BlocProvider.value( @@ -27,6 +37,21 @@ void main() { child: const KycUnsupportedStepPage(), ); + GoRouter buildRouter() => GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => subject()), + GoRoute( + name: SupportRoutes.support, + path: '/support', + builder: (_, _) { + pushedRoutes.add(SupportRoutes.support); + return const Scaffold(body: Text('SUPPORT')); + }, + ), + ], + ); + group('$KycUnsupportedStepPage', () { testWidgets('offers both a retry and a route to support', (tester) async { await tester.pumpApp(subject()); @@ -35,6 +60,23 @@ void main() { expect(find.byType(AppTextButton), findsOne); }); + testWidgets('the support action navigates to the support screen', (tester) async { + await tester.pumpWidget( + MaterialApp.router( + // pumpApp cannot host a router, so the delegates it normally supplies are repeated here + localizationsDelegates: [S.delegate, GlobalMaterialLocalizations.delegate], + supportedLocales: S.delegate.supportedLocales, + routerConfig: buildRouter(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(AppTextButton)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [SupportRoutes.support]); + }); + // The whole point of the page: the previous screen was a dead end with no actions at all. testWidgets('the retry re-reads the KYC state', (tester) async { await tester.pumpApp(subject()); From a818db472b3f3a70dbc993a7a2df381df19bc05b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:05:28 +0200 Subject: [PATCH 11/12] test(goldens): regenerate baselines on the self-hosted runner --- .../kyc_unsupported_step_page_default.png | Bin 33041 -> 38344 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png index 6d44caf6452d966a4f72add5e7fa01d5dfb8b933..87e0a9d5a1737ce8347f533b7588b308c525a3fb 100644 GIT binary patch literal 38344 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 literal 33041 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 Cx|OT| From 8edff43fba219ff59d442f0844bfe06f100272df Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Sun, 2 Aug 2026 15:20:48 -0300 Subject: [PATCH 12/12] test(kyc): make the second-CTA responsive gate real Both reviewers landed on the same finding independently. The support CTA was asserted with find.byType, which resolves a widget regardless of visibility, clipping or hit-testability - so the dead-CTA regression the matrix exists to catch passed 178/178. The justifying comment was wrong too: ScrollableActionsLayout scrolls an over-tall action block rather than overflowing, so a CTA pushed off-screen raises nothing for the overflow assertion to see. The excuse did not hold either - this file already had pumpCompletedPage hosting a page under a GoRouter for exactly this reason, 130 lines above the group I added. Mirrored it, and the CTA is now hit-tested for real: moving it out of the sticky block turns 16 of the 35 cells red. Also bump the catalog entry count the two new surfaces changed. --- test/helper/responsive_surface_catalog.dart | 2 +- ...c_status_pages_responsive_matrix_test.dart | 77 +++++++++++++++---- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index 5903b980..8c35aa01 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -273,7 +273,7 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart', productionPath: 'lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart', ), - // Migration covers 34 surfaces total (bitbox_connect_sheet + 33 above). No + // Migration covers 36 surfaces total (bitbox_connect_sheet + 35 above). No // further known candidates remain from the prior sweep. welcome_page was // reviewed and found safe (scrolls end-to-end, no separate sticky CTA) — not // a migration candidate. Not exhaustive — review responsibility for every diff --git a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart index a8dfcfdc..f016054b 100644 --- a/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart +++ b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/setup/routing/routes/support_routes.dart'; import 'package:realunit_wallet/widgets/buttons/app_text_button.dart'; import 'package:realunit_wallet/screens/kyc/steps/personal_data/kyc_personal_data_page.dart'; import 'package:realunit_wallet/screens/kyc/subpages/kyc_unsupported_step_page.dart'; @@ -77,6 +78,54 @@ void main() { /// Hosts [KycCompletedPage] under a two-route GoRouter so `context.pop` has /// somewhere to go back to (mirrors support_email_capture_page_test). + /// Hosts the unsupported-step page under a router so its support CTA — which navigates — can be + /// really tapped. `pumpPage` cannot: it mounts pages under `MaterialApp(home:)`, and a tap would + /// throw "No GoRouter found in context". + Future pumpUnsupportedStepPage( + WidgetTester tester, + MatrixCell cell, + ) async { + await tester.binding.setSurfaceSize(cell.device.size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => BlocProvider.value( + value: cubit, + child: const KycUnsupportedStepPage(), + ), + ), + GoRoute( + name: SupportRoutes.support, + path: '/support', + builder: (_, _) => const Scaffold(body: Text('SUPPORT')), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: _localizationsDelegates, + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); + + return router; + } + Future pumpCompletedPage( WidgetTester tester, MatrixCell cell, @@ -176,9 +225,8 @@ void main() { } }); - // Two live tap targets in one sticky block — the first of its kind in the KYC family — so both are - // asserted, not just the primary CTA. The DE copy is long enough that the 3.0 text-scale cells are - // the point of this group. + // Two live tap targets in one sticky block, so both are asserted rather than just the primary CTA. + // The DE copy is long enough that the 3.0 text-scale cells are the point of this group. group('KycUnsupportedStepPage responsive matrix (full device × textScale)', () { for (final cell in kFullResponsiveMatrix) { testWidgets(cell.id, (tester) async { @@ -186,14 +234,7 @@ void main() { await expectNoLayoutOverflow( tester, () async { - await pumpPage( - tester, - cell, - BlocProvider.value( - value: cubit, - child: const KycUnsupportedStepPage(), - ), - ); + await pumpUnsupportedStepPage(tester, cell); }, reason: 'overflow on KycUnsupportedStepPage / ${cell.label}', ); @@ -205,11 +246,15 @@ void main() { reason: 'KycUnsupportedStepPage / ${cell.label}: retry not tappable', ); - // The support CTA's reachability is covered by the overflow assertion above — it cannot be - // tapped here because `pumpPage` hosts the page under `MaterialApp(home:)` with no router, - // and the CTA navigates. Its tap target and its route are pinned in the page test, which - // hosts a GoRouter. - expect(find.byType(AppTextButton), findsOne); + // Asserted for real, not inferred from the overflow check: ScrollableActionsLayout scrolls + // an over-tall action block instead of overflowing, so a CTA pushed out of the viewport + // raises nothing. Only a hit test catches it. + await expectFullyTappable( + tester, + find.byType(AppTextButton), + within: find.byType(KycUnsupportedStepPage), + reason: 'KycUnsupportedStepPage / ${cell.label}: support CTA not tappable', + ); }); }); }