diff --git a/README.md b/README.md index 2575e765..abdc459b 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,12 @@ 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`) | | 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 d27b1cae..d92d48fe 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -130,9 +130,12 @@ "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}", "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.", + "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 913f4061..c32239c4 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -130,9 +130,12 @@ "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}", "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.", + "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/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/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart index 89e3c50e..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,12 +338,14 @@ class KycCubit extends Cubit { KycSuccess( currentStep: kycStep, urlOrToken: currentStep.session.url, + realUnitUserData: realUnitUserData, ), ); } 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..dac1f512 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, @@ -43,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/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index c5ae9013..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'; @@ -14,6 +12,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'; @@ -23,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'; @@ -55,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(), @@ -75,6 +73,10 @@ class KycViewManager extends StatelessWidget { }, ), KycStep.registration => KycRegistrationPage(initialUserData: realUnitUserData), + 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 new file mode 100644 index 00000000..d4aab5fe --- /dev/null +++ b/lib/screens/kyc/steps/personal_data/cubit/kyc_personal_data/kyc_personal_data_cubit.dart @@ -0,0 +1,55 @@ +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'; + +class KycPersonalDataCubit extends Cubit { + final DfxKycService _kycService; + + KycPersonalDataCubit(DfxKycService kycService) + : _kycService = kycService, + super(const KycPersonalDataInitial()); + + Future submit({ + required String url, + required KycAccountType accountType, + 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, + KycPersonalData( + // 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, + 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/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..f5994ba3 --- /dev/null +++ b/lib/screens/kyc/steps/personal_data/kyc_personal_data_page.dart @@ -0,0 +1,335 @@ +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/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_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'; +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. +/// +/// 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; + final RealUnitUserDataDto? initialUserData; + + 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. + final userData = initialUserData; + if (userData == null) { + // 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) { + // 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( + create: (_) => KycPersonalDataCubit(getIt()), + child: KycPersonalDataView(url: url, initialUserData: userData), + ); + } +} + +class KycPersonalDataView extends StatefulWidget { + final String url; + final RealUnitUserDataDto initialUserData; + + const KycPersonalDataView({super.key, required this.url, required this.initialUserData}); + + @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); + 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 { + try { + final countries = await getIt().getAllCountries(); + if (!mounted) 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; + } catch (_) { + // Degrade to an empty picker — CountryField renders its own error and retry. + } + } + + 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(); + // 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!, + street: streetCtrl.text, + houseNumber: houseNumberCtrl.text, + zip: zipCtrl.text, + city: cityCtrl.text, + country: countryCtrl.value!, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).personalData)), + 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, + initialValue: _initialCountry, + 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(); + } +} + +/// 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/screens/kyc/subpages/kyc_unsupported_step_page.dart b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart new file mode 100644 index 00000000..675d8f72 --- /dev/null +++ b/lib/screens/kyc/subpages/kyc_unsupported_step_page.dart @@ -0,0 +1,67 @@ +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/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'; +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.headlineMedium, + textAlign: TextAlign.center, + ), + Text( + S.of(context).kycUnsupportedStepDescription, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ], + ), + actions: [ + AppFilledButton( + onPressed: () => context.read().checkKyc(), + label: S.of(context).refresh, + ), + AppTextButton( + onPressed: () => context.pushNamed(SupportRoutes.support), + label: S.of(context).contactSupport, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 469cf364..a2684db8 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -21,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/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 00000000..8c72f9ff Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_default.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_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 00000000..eab60d88 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_personal_data_page_missing_user_data.png differ 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 00000000..87e0a9d5 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_unsupported_step_page_default.png differ 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..79d3328b --- /dev/null +++ b/test/goldens/screens/kyc/kyc_personal_data_golden_test.dart @@ -0,0 +1,99 @@ +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', () { + // 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', + 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/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/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index f45b7dfd..8c35aa01 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', @@ -259,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/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/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart index af5204ce..cf7dc572 100644 --- a/test/screens/kyc/kyc_page_manager_test.dart +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -4,6 +4,11 @@ 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'; +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 +82,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 +134,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. @@ -185,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 new file mode 100644 index 00000000..720475d9 --- /dev/null +++ b/test/screens/kyc/steps/kyc_personal_data_page_test.dart @@ -0,0 +1,341 @@ +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'; +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_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'; +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, + String phone = '+41790000000', + }) => RealUnitUserDataDto( + email: 'erika@example.com', + name: 'Erika Mueller', + type: 'HUMAN', + phoneNumber: phone, + 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: phone, + 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), + ); + registerFallbackValue(KycAccountType.personal); + }); + + 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(KycUnsupportedStepPage), findsOne); + }); + } + + // 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( + value: kycCubit, + child: const KycPersonalDataPage(url: url), + ), + ); + + expect(find.byType(KycPersonalDataView), findsNothing); + expect(find.byType(KycUnsupportedStepPage), findsNothing); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + verify(() => kycCubit.checkKyc()).called(1); + }); + }); + + 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); + }); + + // 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(); + + 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. + 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'), + 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, + accountType: KycAccountType.personal, + 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'), + accountType: any(named: 'accountType'), + 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 new file mode 100644 index 00000000..f5b57bd0 --- /dev/null +++ b/test/screens/kyc/steps/personal_data/kyc_personal_data_cubit_test.dart @@ -0,0 +1,98 @@ +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/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 {} + +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) => c.submit( + url: 'https://kyc/data/personal/1', + accountType: KycAccountType.personal, + firstName: 'Erika', + lastName: 'Mueller', + phone: '+41790000000', + street: 'Bahnhofstrasse', + houseNumber: '13', + 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), + ); + + 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/subpages/kyc_status_pages_responsive_matrix_test.dart b/test/screens/kyc/subpages/kyc_status_pages_responsive_matrix_test.dart index 8f42e609..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,10 @@ 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'; 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'; @@ -74,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, @@ -173,6 +225,72 @@ void main() { } }); + // 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 { + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await pumpUnsupportedStepPage(tester, cell); + }, + reason: 'overflow on KycUnsupportedStepPage / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(KycUnsupportedStepPage), + reason: 'KycUnsupportedStepPage / ${cell.label}: retry not tappable', + ); + + // 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', + ); + }); + }); + } + }); + + 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 new file mode 100644 index 00000000..aa29aaec --- /dev/null +++ b/test/screens/kyc/subpages/kyc_unsupported_step_page_test.dart @@ -0,0 +1,104 @@ +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: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'; +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; + + // 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( + value: kycCubit, + 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()); + + expect(find.byType(AppFilledButton), findsOne); + 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()); + + 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', + ); + } + }); + }); +} 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,