From 954d4955da8a50130a31b5db9a9b6e46a6406c02 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 30 Jul 2026 17:41:30 +0300 Subject: [PATCH] feat(consultant_scoring): added validation errors, fixed scoring flow, updating flow --- lib/core/routing/other_routes.dart | 17 +- .../enums/alfa_scoring_status.dart | 51 ++ .../models/consultant_cert_photo_model.dart | 3 +- .../models/consultant_profile_model.dart | 54 +- .../models/consultation_request_model.dart | 37 +- .../models/sbp_bank_model.dart | 3 +- .../register_consultant_cubit.dart | 369 ++++++++++-- .../register_consultant_state.dart | 11 +- .../pages/additional_settings_screen.dart | 27 +- .../pages/become_consultant_screen.dart | 26 +- .../certificates_and_diplomas_screen.dart | 67 ++- .../presentation/pages/last_step_screen.dart | 82 ++- .../pages/passport_data_screen.dart | 540 ++++++++++-------- .../pages/photo_or_scans_screen.dart | 88 ++- .../pages/registration_as_screen.dart | 62 +- .../pages/social_networks_screen.dart | 111 ++-- .../stepper_become_consultant_screen.dart | 310 +++++++--- .../widgets/passport_field_with_error.dart | 28 + .../widgets/passport_section_title.dart | 34 ++ .../widgets/scoring_status_widget.dart | 218 +++++++ .../repositories/consultant_repository.dart | 39 +- .../services/consultant_service.dart | 32 +- .../utils/laravel_validation_errors.dart | 167 ++++++ .../presentation/pages/settings_screen.dart | 5 +- .../entities/diploma_entity_test.dart | 1 - .../consultant_cert_photo_model_test.dart | 1 - .../models/consultant_profile_model_test.dart | 10 +- .../consultation_request_model_test.dart | 41 +- .../register_consultant_state_test.dart | 21 +- .../bloc/register_consultant_cubit_test.dart | 493 +++++++++++++--- .../additional_settings_screen_test.dart | 19 +- ...icates_and_diplomas_screen_smoke_test.dart | 49 +- .../pages/last_step_screen_test.dart | 74 ++- .../pages/passport_data_screen_test.dart | 9 +- .../photo_or_scans_screen_smoke_test.dart | 50 +- .../pages/registration_as_screen_test.dart | 9 +- .../social_networks_screen_smoke_test.dart | 54 +- ...r_become_consultant_screen_smoke_test.dart | 100 +++- .../consultant_repository_test.dart | 181 ++++-- .../services/consultant_service_test.dart | 62 +- .../utils/laravel_validation_errors_test.dart | 94 +++ .../consultant/consultation_request.json | 2 +- 42 files changed, 2894 insertions(+), 757 deletions(-) create mode 100644 lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart create mode 100644 lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart create mode 100644 test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 91b6fab3..9c1ed729 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -258,7 +258,15 @@ class OtherRoutes { ), // Consultant routes - GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), + GoRoute( + path: RouteConstants.becomeConsultantScreen, + name: 'becomeConsultantScreen', + builder: (context, state) { + final data = state.extra as Map?; + final slug = data?['slug'] as String?; + return BecomeConsultantScreen(slug: slug); + }, + ), GoRoute( path: RouteConstants.stepperBecomeConsultantScreen, name: 'stepperBecomeConsultantScreen', @@ -266,7 +274,12 @@ class OtherRoutes { final data = state.extra as Map; final slug = data['slug'] as String?; final isCanceled = data['isCanceled'] as bool?; - return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled); + final isNewRegistration = data['isNewRegistration'] as bool? ?? false; + return StepperBecomeConsultantScreen( + slug: slug, + isCanceled: isCanceled, + isNewRegistration: isNewRegistration, + ); }, ), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), diff --git a/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart new file mode 100644 index 00000000..60d5b86c --- /dev/null +++ b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart @@ -0,0 +1,51 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +enum AlfaScoringStatus { + @JsonValue('none') + none, + @JsonValue('beneficiary_creating') + beneficiaryCreating, + @JsonValue('beneficiary_failed') + beneficiaryFailed, + @JsonValue('scoring_pending') + scoringPending, + @JsonValue('scoring_complete') + scoringComplete, + @JsonValue('scoring_failed') + scoringFailed; + + static AlfaScoringStatus fromString(String? value) { + switch (value) { + case 'beneficiary_creating': + return AlfaScoringStatus.beneficiaryCreating; + case 'beneficiary_failed': + return AlfaScoringStatus.beneficiaryFailed; + case 'scoring_pending': + return AlfaScoringStatus.scoringPending; + case 'scoring_complete': + return AlfaScoringStatus.scoringComplete; + case 'scoring_failed': + return AlfaScoringStatus.scoringFailed; + case 'none': + case null: + case '': + return AlfaScoringStatus.none; + default: + return AlfaScoringStatus.none; + } + } + + bool get isProcessing => + this == AlfaScoringStatus.beneficiaryCreating || + this == AlfaScoringStatus.scoringPending; + + bool get isFailed => + this == AlfaScoringStatus.beneficiaryFailed || + this == AlfaScoringStatus.scoringFailed; + + bool get canRetry => isFailed; + + bool get isComplete => this == AlfaScoringStatus.scoringComplete; + + bool get isTerminal => isComplete || isFailed; +} diff --git a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart index aaec9b68..79735be5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart @@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel { const ConsultantCertPhotoModel._(); - factory ConsultantCertPhotoModel.fromJson(Map json) => _$ConsultantCertPhotoModelFromJson(json); + factory ConsultantCertPhotoModel.fromJson(Map json) => + _$ConsultantCertPhotoModelFromJson(json); static const String visibleGroup = 'user_certs'; static const String hiddenGroup = 'hidden_user_certs'; diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index dd9e01f5..5d454ff2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -9,35 +9,50 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel { const ConsultantProfileModel._(); const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, + @JsonKey(name: 'has_medical_education', includeIfNull: false) + bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) + SocialLinksModel? socialLinks, @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'completed_courses', includeIfNull: false) + List? certificates, @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, @JsonKey(name: 'inn', includeIfNull: false) String? inn, @JsonKey(name: 'snils', includeIfNull: false) String? snils, - @JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, - @JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, - @JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, - @JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, - @JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, + @JsonKey(name: 'passport_series', includeIfNull: false) + String? passportSeries, + @JsonKey(name: 'passport_number', includeIfNull: false) + String? passportNumber, + @JsonKey(name: 'passport_issue_date', includeIfNull: false) + String? passportIssueDate, + @JsonKey(name: 'passport_issuer', includeIfNull: false) + String? passportIssuer, + @JsonKey(name: 'passport_issuer_code', includeIfNull: false) + String? passportIssuerCode, @JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate, @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, - @JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress, - @JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress, + @JsonKey(name: 'registration_address', includeIfNull: false) + String? registrationAddress, + @JsonKey(name: 'residence_address', includeIfNull: false) + String? residenceAddress, @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, - @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident, + @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) + bool? foreignTaxResident, @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, - @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId, - @JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted, - @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) + String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) + bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) + bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; - factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + factory ConsultantProfileModel.fromJson(Map json) => + _$ConsultantProfileModelFromJson(json); bool get hasValidPassportData { final last = lastName ?? ''; @@ -79,10 +94,12 @@ abstract class SocialLinksModel with _$SocialLinksModel { @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) + String? personalSiteLink, }) = _SocialLinksModel; - factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); + factory SocialLinksModel.fromJson(Map json) => + _$SocialLinksModelFromJson(json); } @freezed @@ -92,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel { required String date, }) = _CertificatesModel; - factory CertificatesModel.fromJson(Map json) => _$CertificatesModelFromJson(json); + factory CertificatesModel.fromJson(Map json) => + _$CertificatesModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 051f517f..c15649fd 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -11,11 +12,13 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'rating_place') int? ratingPlace, @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, - @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'canceled_moderation') + CanceledModerationModel? canceledModeration, @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; - factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); + factory ConsultationRequestModel.fromJson(Map json) => + _$ConsultationRequestModelFromJson(json); } @freezed @@ -28,7 +31,13 @@ abstract class LegalEntityModel with _$LegalEntityModel { String? inn, String? snils, String? state, - @JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, + @JsonKey( + name: 'alfa_scoring_status', + unknownEnumValue: AlfaScoringStatus.none, + defaultValue: AlfaScoringStatus.none, + ) + @Default(AlfaScoringStatus.none) + AlfaScoringStatus alfaScoringStatus, @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, @JsonKey(name: 'last_name') String? lastName, @JsonKey(name: 'first_name') String? firstName, @@ -49,7 +58,8 @@ abstract class LegalEntityModel with _$LegalEntityModel { @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, }) = _LegalEntityModel; - factory LegalEntityModel.fromJson(Map json) => _$LegalEntityModelFromJson(json); + factory LegalEntityModel.fromJson(Map json) => + _$LegalEntityModelFromJson(json); /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. static String? normalizeApiDate(String? value) { @@ -62,25 +72,21 @@ abstract class LegalEntityModel with _$LegalEntityModel { return value; } - String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); + String? get normalizedPassportIssueDate => + normalizeApiDate(passportIssueDate); String? get normalizedBirthDate => normalizeApiDate(birthDate); } @freezed abstract class CertPhotosModel with _$CertPhotosModel { - const factory CertPhotosModel({ - required String url, - }) = _CertPhotosModel; + const factory CertPhotosModel({required String url}) = _CertPhotosModel; - factory CertPhotosModel.fromJson(Map json) => _$CertPhotosModelFromJson(json); + factory CertPhotosModel.fromJson(Map json) => + _$CertPhotosModelFromJson(json); } -enum ModerationStatus { - approved, - created, - canceled, -} +enum ModerationStatus { approved, created, canceled } @freezed abstract class CanceledModerationModel with _$CanceledModerationModel { @@ -90,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel { required List? additional, }) = _CanceledModerationModel; - factory CanceledModerationModel.fromJson(Map json) => _$CanceledModerationModelFromJson(json); + factory CanceledModerationModel.fromJson(Map json) => + _$CanceledModerationModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart index c7bf7976..1ffb0c5f 100644 --- a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -13,5 +13,6 @@ abstract class SbpBankModel with _$SbpBankModel { String? bic, }) = _SbpBankModel; - factory SbpBankModel.fromJson(Map json) => _$SbpBankModelFromJson(json); + factory SbpBankModel.fromJson(Map json) => + _$SbpBankModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index d82ff2bb..9f835e5c 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,30 +1,52 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:eatvio_app/core/error_reporting/report_from.dart'; class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; + final Duration scoringPollInterval; bool _update = false; + String? _userSlug; + Timer? _scoringPollTimer; static const String individualPersonLegalType = 'individualPerson'; - RegisterConsultantCubit({required this.consultantRepository}) - : super( - const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), - loading: true, - acceptOffer: false, - acceptFrdoAgreement: false, - isUpdating: false, - ), - ); + RegisterConsultantCubit({ + required this.consultantRepository, + this.scoringPollInterval = const Duration(seconds: 3), + }) : super( + const RegisterConsultantState( + consultant: ConsultantProfileModel( + socialLinks: SocialLinksModel(), + hasMedicalEducation: true, + legalType: individualPersonLegalType, + ), + loading: true, + acceptOffer: false, + acceptFrdoAgreement: false, + isUpdating: false, + ), + ); + + String? get userSlug => _userSlug; + + void setUserSlug(String? slug) { + if (slug != null && slug.isNotEmpty) { + _userSlug = slug; + } + } Future> _loadSbpBanks() async { try { @@ -45,7 +67,9 @@ class RegisterConsultantCubit extends Cubit { final name = bank.bankName.toLowerCase(); final shortName = (bank.shortName ?? '').toLowerCase(); final bic = (bank.bic ?? '').toLowerCase(); - return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized); + return name.contains(normalized) || + shortName.contains(normalized) || + bic.contains(normalized); }).toList(); } @@ -64,21 +88,29 @@ class RegisterConsultantCubit extends Cubit { Future getMyRequestStatus(String slug) async { try { _update = true; - final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); + _userSlug = slug; + final myRequestStatus = await consultantRepository.getMyRequestStatus( + slug, + ); final certPhotos = await consultantRepository.getConsultantCertPhotos(); final sbpBanks = await _loadSbpBanks(); - List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { - final course = courseMap.values.first; + List? certificates = myRequestStatus + .educationModel + .completedCourses + ?.map((courseMap) { + final course = courseMap.values.first; - return CertificatesModel(title: course.title, date: course.date); - }).toList(); + return CertificatesModel(title: course.title, date: course.date); + }) + .toList(); String? sanitizeLink(String? link) { return (link != null && link.isNotEmpty) ? link : null; } final legal = myRequestStatus.legalEntity; + final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none; emit( RegisterConsultantState( @@ -89,9 +121,12 @@ class RegisterConsultantCubit extends Cubit { tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), - personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), + personalSiteLink: sanitizeLink( + myRequestStatus.educationModel.personalSiteLink, + ), ), - hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + hasMedicalEducation: + myRequestStatus.educationModel.hasMedicalEducation == 1, legalType: legal?.type ?? individualPersonLegalType, lastName: _emptyToNull(legal?.lastName), firstName: _emptyToNull(legal?.firstName), @@ -120,13 +155,30 @@ class RegisterConsultantCubit extends Cubit { certPhotos: certPhotos, sbpBanks: sbpBanks, isUpdating: _update, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + fieldErrors: const {}, error: null, ), ); + + if (scoringStatus.isProcessing) { + startScoringPoll(); + } } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -137,8 +189,9 @@ class RegisterConsultantCubit extends Cubit { return value.trim(); } - Future disableLoading() async { + Future disableLoading({String? slug}) async { try { + setUserSlug(slug); final certPhotos = await consultantRepository.getConsultantCertPhotos(); final sbpBanks = await _loadSbpBanks(); emit( @@ -147,6 +200,8 @@ class RegisterConsultantCubit extends Cubit { certPhotos: certPhotos, sbpBanks: sbpBanks, error: null, + fieldErrors: const {}, + awaitingScoringOutcome: false, consultant: state.consultant.copyWith( legalType: individualPersonLegalType, citizenship: state.consultant.citizenship, @@ -158,7 +213,15 @@ class RegisterConsultantCubit extends Cubit { } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -168,11 +231,15 @@ class RegisterConsultantCubit extends Cubit { if (slug != null) { await getMyRequestStatus(slug); } else { - await disableLoading(); + await disableLoading(slug: _userSlug); } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { + void updateConsultantProfileModel( + ConsultantProfileModel consultant, [ + bool? acceptOffer, + bool? acceptFrdoAgreement, + ]) { emit( state.copyWith( consultant: consultant, @@ -180,12 +247,19 @@ class RegisterConsultantCubit extends Cubit { acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, loading: false, error: null, + fieldErrors: const {}, ), ); } void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { - emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); + emit( + state.copyWith( + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + error: null, + ), + ); } bool get hasAcceptedRequiredAgreements { @@ -202,7 +276,13 @@ class RegisterConsultantCubit extends Cubit { taxResidentUs: state.consultant.taxResidentUs ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false, certificates: state.consultant.certificates - ?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date)) + ?.map( + (course) => CertificatesModel( + title: course.title, + date: + LegalEntityModel.normalizeApiDate(course.date) ?? course.date, + ), + ) .toList(), ); @@ -235,40 +315,241 @@ class RegisterConsultantCubit extends Cubit { ); } - return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); + return withLegalType.copyWith( + agreementAccepted: state.acceptOffer, + frdoAgreementAccepted: state.acceptFrdoAgreement, + ); } Future registerConsultant() async { - emit(state.copyWith(error: null)); + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } + + // Show scoring widget immediately while request / job are in progress. + emit( + state.copyWith( + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + awaitingScoringOutcome: true, + scoringRetryInProgress: false, + ), + ); + try { - if (!state.consultant.hasValidPassportData) { - emit(state.copyWith(error: 'Заполните паспортные данные')); - return; - } if (state.newAddedPhotos.isNotEmpty) { - await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); + await consultantRepository.storeConsultantCertPhotos( + files: state.newAddedPhotos, + ); } if (state.deletedPhotoIds.isNotEmpty) { for (final int id in state.deletedPhotoIds) { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: _update, + ); + _update = true; + emit( + state.copyWith( + isUpdating: true, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + scoringRetryInProgress: false, + awaitingScoringOutcome: true, + newAddedPhotos: const [], + deletedPhotoIds: const [], + fieldErrors: const {}, + error: null, + ), + ); + startScoringPoll(); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(error: e.toString())); + emit( + state.copyWith( + error: e.toString(), + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } } + Future retryScoring() async { + if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) { + return; + } + emit( + state.copyWith( + scoringRetryInProgress: true, + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + awaitingScoringOutcome: true, + ), + ); + _update = true; + try { + if (!state.consultant.hasValidPassportData) { + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: 'Заполните паспортные данные', + ), + ); + return; + } + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: true, + ); + startScoringPoll(); + emit( + state.copyWith( + scoringRetryInProgress: false, + isUpdating: true, + awaitingScoringOutcome: true, + ), + ); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + ), + ); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: e.toString(), + ), + ); + } + } + + void startScoringPoll() { + final slug = _userSlug; + if (slug == null || slug.isEmpty) { + emit( + state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false), + ); + return; + } + _stopScoringPoll(); + emit(state.copyWith(isScoringPolling: true)); + _scoringPollTimer = Timer.periodic( + scoringPollInterval, + (_) => _pollScoringStatus(slug), + ); + unawaited(_pollScoringStatus(slug)); + } + + Future _pollScoringStatus(String slug) async { + if (isClosed) { + return; + } + try { + final status = await consultantRepository.getMyRequestStatus(slug); + if (isClosed) { + return; + } + final scoringStatus = + status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none; + // API stays on `none` until ProcessConsultantScoringJob starts + // (ScoringStarted → beneficiary_creating). Keep optimistic processing + // UI and continue polling until a real terminal/processing status arrives. + if (scoringStatus == AlfaScoringStatus.none) { + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + ), + ); + return; + } + final isTerminal = scoringStatus.isTerminal; + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + // Clear session flag on terminal so success sheet can fire once + // (listenWhen: previous.awaiting && !current.awaiting && complete). + awaitingScoringOutcome: isTerminal + ? false + : state.awaitingScoringOutcome, + ), + ); + if (!scoringStatus.isProcessing) { + _stopScoringPoll(); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + // Keep polling on transient errors while scoring is in progress. + } + } + + void _stopScoringPoll() { + _scoringPollTimer?.cancel(); + _scoringPollTimer = null; + } + Future addCertPhotos(List files) async { if (files.isEmpty) { return; } try { - final compressedFiles = await ImageCompressUtility.compressFilesList(files); + final compressedFiles = await ImageCompressUtility.compressFilesList( + files, + ); - emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null)); + emit( + state.copyWith( + newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], + error: null, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -282,7 +563,19 @@ class RegisterConsultantCubit extends Cubit { return; } if (path != null) { - emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); + emit( + state.copyWith( + newAddedPhotos: state.newAddedPhotos + .where((file) => file.uri.path != path) + .toList(), + ), + ); } } + + @override + Future close() { + _stopScoringPoll(); + return super.close(); + } } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index a39a1313..42daeb1e 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -17,10 +18,18 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, - @Default([]) List certPhotos, + @Default([]) + List certPhotos, @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, + @Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus, + @Default(false) bool isScoringPolling, + @Default(false) bool scoringRetryInProgress, + + /// True only after successful register/retry in this session until scoring is terminal. + @Default(false) bool awaitingScoringOutcome, + @Default({}) Map fieldErrors, String? error, }) = _RegisterConsultantState; } diff --git a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart index ce13f905..90b673a5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart @@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget { const AdditionalSettingsScreen({super.key}); @override - State createState() => _AdditionalSettingsScreenState(); + State createState() => + _AdditionalSettingsScreenState(); } class _AdditionalSettingsScreenState extends State { @@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State { @override void initState() { - if (context.read().state.consultant.hasMedicalEducation != null) { + if (context + .read() + .state + .consultant + .hasMedicalEducation != + null) { haveMedicalDegree = context .read() .state @@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State { value: publishDiplomasAndCertificatesInProfile, onTap: () { setState(() { - publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile; + publishDiplomasAndCertificatesInProfile = + !publishDiplomasAndCertificatesInProfile; }); }, ), @@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State { setState(() { haveMedicalDegree = !haveMedicalDegree; }); - context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - hasMedicalEducation: haveMedicalDegree, - ), - ); + context + .read() + .updateConsultantProfileModel( + context + .read() + .state + .consultant + .copyWith(hasMedicalEducation: haveMedicalDegree), + ); }, ), ], diff --git a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart index 40d4a352..2955ee09 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class BecomeConsultantScreen extends StatefulWidget { - const BecomeConsultantScreen({super.key}); + final String? slug; + + const BecomeConsultantScreen({super.key, this.slug}); @override State createState() => _BecomeConsultantScreenState(); @@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State { title: const Text('Стать консультантом'), automaticallyImplyLeading: false, centerTitle: false, - leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)), + leading: IconButton( + onPressed: () => GoRouter.of(context).pop(), + icon: const Icon(Icons.close), + ), ), body: const Padding( padding: EdgeInsets.all(16), @@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State { InkWell _startRegistrationButton() { return InkWell( - onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}), + onTap: () => GoRouter.of(context).pushReplacementNamed( + 'stepperBecomeConsultantScreen', + extra: {'slug': widget.slug, 'isNewRegistration': true}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), child: const Center( child: Text( 'Начать регистрацию', - style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), + style: TextStyle( + color: Colors.white, + height: 1, + fontWeight: FontWeight.w600, + fontSize: 18, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index afff462e..33772eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -13,10 +13,12 @@ class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @override - State createState() => _CertificatesAndDiplomasScreenState(); + State createState() => + _CertificatesAndDiplomasScreenState(); } -class _CertificatesAndDiplomasScreenState extends State { +class _CertificatesAndDiplomasScreenState + extends State { final scrollController = ScrollController(); List diplomasList = [ @@ -30,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State().state.consultant.certificates != null) { + if (context.read().state.consultant.certificates != + null) { diplomasList = [ - ...context.read().state.consultant.certificates!.map( + ...context + .read() + .state + .consultant + .certificates! + .map( (e) => DiplomaEntity( date: DateTime.tryParse(e.date), textEditingController: TextEditingController(text: e.title), @@ -71,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State _diplomaInfo(diploma: diplomasList[index]), + itemBuilder: (context, index) => + _diplomaInfo(diploma: diplomasList[index]), separatorBuilder: (context, index) => Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: _divider(context), @@ -94,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - certificates: diplomasList - .map( - (e) => CertificatesModel( - title: e.textEditingController.text, - date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), - ), - ) - .toList() - .sublist(0, diplomasList.length - 1), + context.read().state.consultant.copyWith( + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null + ? '' + : DateFormat('yyyy-MM-dd').format(e.date!), ), - ); + ) + .toList() + .sublist(0, diplomasList.length - 1), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index afdcbf83..5e87ba50 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -1,8 +1,10 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,7 +26,8 @@ class _LastStepScreenState extends State { @override void initState() { super.initState(); - _frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + _frdoTapRecognizer = TapGestureRecognizer() + ..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); } @override @@ -46,8 +49,22 @@ class _LastStepScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Последний шаг', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), + if (state.awaitingScoringOutcome || + state.isScoringPolling || + state.alfaScoringStatus != + AlfaScoringStatus.none) ...[ + const SizedBox(height: 16), + const ScoringStatusWidget(), + ], if (state.requestModel?.canceledModeration != null) ...[ const SizedBox(height: 16), Padding( @@ -64,12 +81,24 @@ class _LastStepScreenState extends State { children: [ Text( state.requestModel!.canceledModeration!.title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.2, + ), ), - if (state.requestModel!.canceledModeration!.additional != null) ...[ + if (state.requestModel!.canceledModeration!.additional != + null) ...[ const SizedBox(height: 8), - for (final item in state.requestModel!.canceledModeration!.additional!) ...[ - Text('• $item', style: const TextStyle(fontSize: 14, height: 1.3)), + for (final item + in state + .requestModel! + .canceledModeration! + .additional!) ...[ + Text( + '• $item', + style: const TextStyle(fontSize: 14, height: 1.3), + ), ], ], ], @@ -98,9 +127,16 @@ class _LastStepScreenState extends State { value: state.acceptFrdoAgreement, title: Text.rich( TextSpan( - style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), + style: const TextStyle( + fontSize: 18, + height: 1.2, + color: Color(0xFF323F4B), + ), children: [ - const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), + const TextSpan( + text: + 'Обязательное требование: Один из моих документов об образовании есть в реестре ', + ), TextSpan( text: 'ФРДО', style: TextStyle( @@ -120,16 +156,31 @@ class _LastStepScreenState extends State { controlAffinity: ListTileControlAffinity.leading, ), ], - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( onPressed: () async => await launchUrl( - Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf'), + Uri.parse( + hidePlans + ? '${sl().baseUrl}/docs/privacy.pdf' + : '${sl().baseUrl}/oferta_bs.pdf', + ), ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', - style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), + hidePlans + ? 'Политика конфиденциальности и пользовательское соглашение' + : 'Договор оферты', + style: const TextStyle( + height: 1, + fontSize: 16, + color: Color(0xFF323F4B), + decoration: TextDecoration.underline, + ), ), ), ), @@ -142,7 +193,10 @@ class _LastStepScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index 814f2139..466f4418 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -1,8 +1,11 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -100,9 +103,7 @@ class _PassportDataScreenState extends State { birthPlaceController = TextEditingController( text: consultant.birthPlace ?? '', ); - citizenshipController = TextEditingController( - text: consultant.citizenship, - ); + citizenshipController = TextEditingController(text: consultant.citizenship); registrationAddressController = TextEditingController( text: consultant.registrationAddress ?? '', ); @@ -269,258 +270,321 @@ class _PassportDataScreenState extends State { } } - Widget _sectionTitle(String title) { - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 12), - child: Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ); - } - @override Widget build(BuildContext context) { return BlocListener( listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, listener: (context, state) => _hydrateSbpBankNameIfNeeded(), - child: SingleChildScrollView( - padding: const EdgeInsets.only( - bottom: 80, - right: 16, - left: 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Паспортные данные', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.1, - ), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.fieldErrors != current.fieldErrors, + builder: (context, state) { + final errors = state.fieldErrors; + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, ), - _sectionTitle('Персональные данные (как в паспорте)'), - CustomTextFormField( - hintText: 'Фамилия', - controller: lastNameController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Имя', - controller: firstNameController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Отчество', - controller: middleNameController, - onChanged: (_) => _syncToCubit(), - ), - _sectionTitle('Идентификация'), - CustomTextFormField( - hintText: 'ИНН (12 цифр)', - controller: innController, - keyboardType: TextInputType.number, - maxLength: 12, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'СНИЛС', - controller: snilsController, - keyboardType: TextInputType.number, - inputFormatters: [snilsMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Дата рождения (ДД.ММ.ГГГГ)', - controller: birthDateController, - keyboardType: TextInputType.number, - inputFormatters: [dateMaskBirth], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Место рождения', - controller: birthPlaceController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Гражданство (ISO, например RU)', - controller: citizenshipController, - maxLength: 2, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Адрес регистрации', - controller: registrationAddressController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Адрес проживания', - controller: residenceAddressController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 8), - CheckboxListTile( - value: taxResidentUs, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: appTheme.blueA400, - title: const Text( - 'Я являюсь налоговым резидентом США', - style: TextStyle(fontSize: 16, height: 1.2), - ), - onChanged: (value) { - setState(() => taxResidentUs = value ?? false); - _syncToCubit(); - }, - ), - CheckboxListTile( - value: foreignTaxResident, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: appTheme.blueA400, - title: const Text( - 'Я являюсь иностранным налоговым резидентом', - style: TextStyle(fontSize: 16, height: 1.2), - ), - onChanged: (value) { - setState(() => foreignTaxResident = value ?? false); - _syncToCubit(); - }, - ), - _sectionTitle('Паспорт'), - Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + const PassportScreenTitle(title: 'Паспортные данные'), + const PassportSectionTitle( + title: 'Персональные данные (как в паспорте)', + ), + PassportFieldWithError( + error: errors['last_name'], child: CustomTextFormField( - hintText: 'Серия', - controller: seriesController, - keyboardType: TextInputType.number, - maxLength: 4, + hintText: 'Фамилия', + controller: lastNameController, + hasError: errors.containsKey('last_name'), onChanged: (_) => _syncToCubit(), ), ), - const SizedBox(width: 8), - Expanded( + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['first_name'], child: CustomTextFormField( - hintText: 'Номер', - controller: numberController, - keyboardType: TextInputType.number, - maxLength: 6, + hintText: 'Имя', + controller: firstNameController, + hasError: errors.containsKey('first_name'), onChanged: (_) => _syncToCubit(), ), ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['middle_name'], + child: CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + hasError: errors.containsKey('middle_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Идентификация'), + PassportFieldWithError( + error: errors['inn'], + child: CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + hasError: errors.containsKey('inn'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['snils'], + child: CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + hasError: errors.containsKey('snils'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_date'], + child: CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + hasError: errors.containsKey('birth_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_place'], + child: CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + hasError: errors.containsKey('birth_place'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['citizenship'], + child: CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + hasError: errors.containsKey('citizenship'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['registration_address'], + child: CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + hasError: errors.containsKey('registration_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['residence_address'], + child: CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + hasError: errors.containsKey('residence_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь налоговым резидентом США', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + const PassportSectionTitle(title: 'Паспорт'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: PassportFieldWithError( + error: errors['passport_series'], + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + hasError: errors.containsKey('passport_series'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: PassportFieldWithError( + error: errors['passport_number'], + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + hasError: errors.containsKey('passport_number'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issue_date'], + child: CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + hasError: errors.containsKey('passport_issue_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer_code'], + child: CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + hasError: errors.containsKey('passport_issuer_code'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer'], + child: CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + hasError: errors.containsKey('passport_issuer'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Реквизиты СБП'), + PassportFieldWithError( + error: errors['sbp_phone'], + child: CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + hasError: errors.containsKey('sbp_phone'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['sbp_bank_member_id'], + child: TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => + CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + hasError: errors.containsKey('sbp_bank_member_id'), + onChanged: _clearSbpBankIfNeeded, + ), + suggestionsCallback: (query) async { + return context + .read() + .searchSbpBanks(query); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: TextStyles.textMMedium.copyWith( + color: appTheme.black900, + ), + ), + subtitle: + bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: appTheme.whiteA700, + child: child, + ), + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ), + onSelected: _selectSbpBank, + ), + ), + const SizedBox(height: 16), + Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyles.textSMedium.copyWith( + color: appTheme.blueA400, + ), + ), ], ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', - controller: issueDateController, - keyboardType: TextInputType.number, - inputFormatters: [dateMaskIssue], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Код подразделения', - controller: issuerCodeController, - keyboardType: TextInputType.number, - inputFormatters: [issuerCodeMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Кем выдан', - controller: issuerController, - onChanged: (_) => _syncToCubit(), - ), - _sectionTitle('Реквизиты СБП'), - CustomTextFormField( - hintText: 'Телефон для выплат через СБП', - controller: sbpPhoneController, - keyboardType: TextInputType.phone, - inputFormatters: [phoneMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - TypeAheadField( - debounceDuration: const Duration(milliseconds: 250), - controller: sbpBankController, - suggestionsController: sbpBankSuggestionsController, - builder: (context, controller, focusNode) => CustomTextFormField( - controller: controller, - focusNode: focusNode, - hintText: 'Банк получатель (поиск)', - onChanged: (value) { - _clearSbpBankIfNeeded(value); - }, - ), - suggestionsCallback: (query) async { - return context.read().searchSbpBanks( - query, - ); - }, - itemBuilder: (context, bank) { - return ListTile( - dense: true, - title: Text( - bank.bankName, - style: const TextStyle(fontSize: 16, color: Colors.black), - ), - subtitle: bank.shortName == null || bank.shortName!.isEmpty - ? null - : Text( - bank.shortName!, - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), - ); - }, - decorationBuilder: (context, child) => Material( - type: MaterialType.card, - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: child, - ), - emptyBuilder: (context) => const Padding( - padding: EdgeInsets.all(12), - child: Text( - 'Ничего не найдено', - style: TextStyle(color: Colors.black54), - ), - ), - onSelected: _selectSbpBank, - ), - const SizedBox(height: 16), - const Divider(height: 0.4, thickness: 0.4, color: Colors.black26), - const SizedBox(height: 16), - Text( - 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', - style: TextStyle( - fontSize: 14, - height: 1.2, - color: appTheme.blueA400, - ), - ), - ], - ), + ); + }, ), ); } diff --git a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart index cf21b646..87269eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart @@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + const Text( + 'Загрузите фото или сканы дипломов и сертификатов', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), const SizedBox(height: 16), BlocBuilder( builder: (context, state) { - final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); + final photos = state.certPhotos + .where((photo) => !state.deletedPhotoIds.contains(photo.id)) + .toList(); final addedPhotos = state.newAddedPhotos; final totalPhotosCount = photos.length + addedPhotos.length; return Column( children: [ - if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], + if (isUploading) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 16), + ], GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 3 / 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 3 / 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), itemBuilder: (BuildContext context, int index) { if (index == totalPhotosCount) { return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _pickAndUploadPhotos(), child: Ink( - decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: const Color(0xFFF6F6F6), + borderRadius: BorderRadius.circular(20), + ), child: Icon(Icons.add, color: appTheme.blueA400), ), ); @@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State { return _LocalCertPhotoCard( photo: localPhoto, allPhotos: addedPhotos, - onRemove: () => _removePhoto(path: localPhoto.uri.path), + onRemove: () => + _removePhoto(path: localPhoto.uri.path), ); } } @@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State { }); try { // ignore: use_build_context_synchronously - await context.read().addCertPhotos(newImages.map((file) => File(file.path)).toList()); + await context.read().addCertPhotos( + newImages.map((file) => File(file.path)).toList(), + ); } finally { if (mounted) { setState(() { @@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _CertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget { onTap: () { context.pushTransparentRoute( rootNavigator: true, - PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)), + PhotoViewScreen( + imageUrls: allPhotos.map((e) => e.url).toList(), + isLocal: false, + index: allPhotos.indexOf(photo), + ), ); }, borderRadius: BorderRadius.circular(20), @@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget { Ink( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(20)), - image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)), + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage(photo.url), + ), ), ), Positioned( @@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), @@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _LocalCertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index a754de27..e10fc74a 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -13,7 +13,8 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - static const RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = + RegistrationAsType.physical; @override void initState() { @@ -24,7 +25,9 @@ class _RegistrationAsScreenState extends State { } final cubit = context.read(); cubit.updateConsultantProfileModel( - cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), + cubit.state.consultant.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + ), cubit.state.acceptOffer, cubit.state.acceptFrdoAgreement, ); @@ -41,7 +44,14 @@ class _RegistrationAsScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Регистрация в качестве', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), const SizedBox(height: 16), Column( @@ -59,7 +69,10 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Физического лица', + style: TextStyle(fontSize: 18, height: 1), + ), ), ), ], @@ -77,7 +90,14 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Самозанятого', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -95,7 +115,14 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Индивидуального предпринимателя', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -113,17 +140,29 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Общества с ограниченной ответственностью', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], ), ], ), - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), + child: Text( + '* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся', + ), ), ], ), @@ -134,7 +173,10 @@ class _RegistrationAsScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart index 609e94a5..1847ec60 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart @@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State { var maskFormatter = MaskTextInputFormatter( mask: 'https://########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskTgFormatter = MaskTextInputFormatter( mask: 'https://tg.me/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskVkFormatter = MaskTextInputFormatter( mask: 'https://vk.com/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); @override void initState() { super.initState(); - vkController.text = context.read().state.consultant.socialLinks!.vkLink ?? ''; - telegramController.text = context.read().state.consultant.socialLinks!.tgLink ?? ''; - webSiteController.text = context.read().state.consultant.socialLinks!.personalSiteLink ?? ''; + vkController.text = + context + .read() + .state + .consultant + .socialLinks! + .vkLink ?? + ''; + telegramController.text = + context + .read() + .state + .consultant + .socialLinks! + .tgLink ?? + ''; + webSiteController.text = + context + .read() + .state + .consultant + .socialLinks! + .personalSiteLink ?? + ''; } @override @@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/vk.svg', - ), + child: SvgPicture.asset('assets/icons/vk.svg'), ), const SizedBox(width: 8), SizedBox( @@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/telegram.svg', - ), + child: SvgPicture.asset('assets/icons/telegram.svg'), ), const SizedBox(width: 8), SizedBox( @@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/web.svg', - ), + child: SvgPicture.asset('assets/icons/web.svg'), ), const SizedBox(width: 8), SizedBox( @@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State { String? personalSiteLink, }) { context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - socialLinks: context.read().state.consultant.socialLinks?.copyWith( - tgLink: tgLink == null - ? context.read().state.consultant.socialLinks?.tgLink - : tgLink.isEmpty - ? null - : tgLink, - vkLink: vkLink == null - ? context.read().state.consultant.socialLinks?.vkLink - : vkLink.isEmpty - ? null - : vkLink, - personalSiteLink: personalSiteLink == null - ? context.read().state.consultant.socialLinks?.personalSiteLink - : personalSiteLink.isEmpty - ? null - : personalSiteLink, - ), - ), - ); + context.read().state.consultant.copyWith( + socialLinks: context + .read() + .state + .consultant + .socialLinks + ?.copyWith( + tgLink: tgLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.tgLink + : tgLink.isEmpty + ? null + : tgLink, + vkLink: vkLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.vkLink + : vkLink.isEmpty + ? null + : vkLink, + personalSiteLink: personalSiteLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.personalSiteLink + : personalSiteLink.isEmpty + ? null + : personalSiteLink, + ), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 73359e25..12b016af 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; @@ -22,26 +23,37 @@ import 'package:lottie/lottie.dart'; class StepperBecomeConsultantScreen extends StatefulWidget { final String? slug; final bool? isCanceled; - const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); + final bool isNewRegistration; + const StepperBecomeConsultantScreen({ + super.key, + this.slug, + this.isCanceled, + this.isNewRegistration = false, + }); @override - State createState() => _StepperBecomeConsultantScreenState(); + State createState() => + _StepperBecomeConsultantScreenState(); } -class _StepperBecomeConsultantScreenState extends State { +class _StepperBecomeConsultantScreenState + extends State { double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; bool isLoading = false; + bool _successSheetShown = false; @override void initState() { super.initState(); - if (widget.slug == null) { - registrationConsultantCubit = sl()..disableLoading(); + if (widget.isNewRegistration || widget.slug == null) { + registrationConsultantCubit = sl() + ..disableLoading(slug: widget.slug); } else { - registrationConsultantCubit = sl()..getMyRequestStatus(widget.slug!); + registrationConsultantCubit = sl() + ..getMyRequestStatus(widget.slug!); } } @@ -59,16 +71,97 @@ class _StepperBecomeConsultantScreenState extends State _showSuccessSheet(BuildContext context) async { + if (_successSheetShown) { + return; + } + _successSheetShown = true; + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + height: 1, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Lottie.asset( + 'assets/lottie_animations/success.json', + width: 160, + height: 160, + ), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + height: 1, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return BlocProvider( create: (context) => registrationConsultantCubit, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => + previous.awaitingScoringOutcome && + !current.awaitingScoringOutcome && + current.alfaScoringStatus.isComplete, + listener: (context, state) { + _showSuccessSheet(context); + }, builder: (context, state) { if (state.loading) { return const Scaffold(body: Loader()); } - if (state.error != null && state.requestModel == null && widget.slug != null) { + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { return Scaffold( appBar: AppBar( centerTitle: true, @@ -87,11 +180,15 @@ class _StepperBecomeConsultantScreenState extends State context.read().reloadData(widget.slug), + onReload: () => context + .read() + .reloadData(widget.slug), ), ); } @@ -100,8 +197,14 @@ class _StepperBecomeConsultantScreenState extends State Navigator.pop(context), child: const Text('OK'))], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], ), ); }, @@ -150,11 +268,19 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Заполните паспортные данные'), + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: const Text( + 'Заполните паспортные данные', + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), ); return; } - if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { setState(() { isLoading = true; }); try { - await registrationConsultantCubit.registerConsultant(); + await registrationConsultantCubit + .registerConsultant(); if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), - ), + if (registrationConsultantCubit.state.error != + null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, ), + content: Text( + registrationConsultantCubit + .state + .error!, + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => + Navigator.of(context).pop(), ), ], ), - ), - ); + ); + return; + } + // Success sheet is shown by BlocListener when scoring completes. } catch (e) { if (!context.mounted) return; showDialog( context: context, builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), @@ -275,7 +392,9 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), content: Text( state.isUpdating ? 'Примите договор публичной оферты' @@ -295,17 +414,25 @@ class _StepperBecomeConsultantScreenState extends State IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), + builder: (context) => IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => GoRouter.of(context).pop(), + ), ), centerTitle: true, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], + title: SvgPicture.asset( + ImageConstant.imgLogoEatvio, + height: 48, + fit: BoxFit.contain, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('шаг $step из $totalSteps'), + ), + ], bottom: PreferredSize( preferredSize: Size(MediaQuery.of(context).size.width, 32), child: Padding( @@ -326,13 +465,18 @@ class _StepperBecomeConsultantScreenState extends State( duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, - tween: Tween(begin: 1 / totalSteps, end: progressValue), + tween: Tween( + begin: 1 / totalSteps, + end: progressValue, + ), builder: (context, value, _) => LinearProgressIndicator( value: value, borderRadius: BorderRadius.circular(16), minHeight: 8, backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), + valueColor: AlwaysStoppedAnimation( + appTheme.blueA400, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart new file mode 100644 index 00000000..5e34fa79 --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart @@ -0,0 +1,28 @@ +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; + +/// Wraps a form field with [ValidationErrorWidget] when [error] is present. +/// Pass [hasError] into [CustomTextFormField] from the parent. +class PassportFieldWithError extends StatelessWidget { + const PassportFieldWithError({required this.child, this.error, super.key}); + + final Widget child; + final String? error; + + @override + Widget build(BuildContext context) { + final message = error?.trim(); + final showError = message != null && message.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + if (showError) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: message), + ], + ], + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart new file mode 100644 index 00000000..29abf8af --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart @@ -0,0 +1,34 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class PassportSectionTitle extends StatelessWidget { + const PassportSectionTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: TextStyles.textLSemibold.copyWith(color: appTheme.black), + ), + ); + } +} + +class PassportScreenTitle extends StatelessWidget { + const PassportScreenTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: TextStyles.textFS24FW700.copyWith(color: appTheme.black), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart new file mode 100644 index 00000000..764c803d --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart @@ -0,0 +1,218 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ScoringStatusWidget extends StatelessWidget { + const ScoringStatusWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.alfaScoringStatus != current.alfaScoringStatus || + previous.isScoringPolling != current.isScoringPolling || + previous.scoringRetryInProgress != current.scoringRetryInProgress || + previous.awaitingScoringOutcome != current.awaitingScoringOutcome, + builder: (context, state) { + final isWaiting = + state.awaitingScoringOutcome || state.isScoringPolling; + final status = state.alfaScoringStatus; + final showProcessing = status.isProcessing || + (isWaiting && !status.isTerminal); + + if (status == AlfaScoringStatus.none && !isWaiting) { + return const SizedBox.shrink(); + } + + final displayStatus = showProcessing && !status.isProcessing + ? AlfaScoringStatus.beneficiaryCreating + : status; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: appTheme.greyD9), + color: appTheme.whiteA700, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScoringStatusHeader(status: displayStatus), + const SizedBox(height: 12), + if (showProcessing) const ScoringProcessingBody(), + if (status.isComplete) const ScoringCompleteBody(), + if (status.isFailed) + ScoringFailedBody( + retryInProgress: state.scoringRetryInProgress, + onRetry: () => context + .read() + .retryScoring(), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class ScoringStatusHeader extends StatelessWidget { + const ScoringStatusHeader({required this.status, super.key}); + + final AlfaScoringStatus status; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + 'Проверка данных', + style: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ), + ), + Text( + ScoringStatusLabels.hintFor(status), + style: TextStyles.textSMedium.copyWith( + color: ScoringStatusLabels.colorFor(status), + ), + ), + ], + ); + } +} + +class ScoringProcessingBody extends StatelessWidget { + const ScoringProcessingBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.blueA400, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Идёт проверка…', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringCompleteBody extends StatelessWidget { + const ScoringCompleteBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.check_circle, color: appTheme.green, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Проверка успешно завершена', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringFailedBody extends StatelessWidget { + const ScoringFailedBody({ + required this.retryInProgress, + required this.onRetry, + super.key, + }); + + final bool retryInProgress; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ValidationErrorWidget(error: 'Не удалось пройти проверку'), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: retryInProgress ? null : onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: appTheme.blueA400, + disabledBackgroundColor: appTheme.greyD9, + foregroundColor: appTheme.whiteA700, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: retryInProgress + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.whiteA700, + ), + ) + : Text( + 'Повторить', + style: TextStyles.textMSemibold.copyWith( + color: appTheme.whiteA700, + ), + ), + ), + ), + ], + ); + } +} + +abstract final class ScoringStatusLabels { + static String hintFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.beneficiaryCreating => 'Создание профиля', + AlfaScoringStatus.scoringPending => 'Ожидание результата', + AlfaScoringStatus.scoringComplete => 'Успешно', + AlfaScoringStatus.beneficiaryFailed => 'Ошибка', + AlfaScoringStatus.scoringFailed => 'Ошибка', + AlfaScoringStatus.none => '', + }; + } + + static Color colorFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.scoringComplete => appTheme.green, + AlfaScoringStatus.beneficiaryFailed || + AlfaScoringStatus.scoringFailed => appTheme.red, + AlfaScoringStatus.beneficiaryCreating || + AlfaScoringStatus.scoringPending => appTheme.blueA400, + AlfaScoringStatus.none => appTheme.grey7B, + }; + } +} diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index b8d55b5a..4bc97dce 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -12,16 +12,25 @@ class ConsultantRepository { const ConsultantRepository({required this.consultantService}); - Future registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async { + Future registerConsultant({ + required ConsultantProfileModel consultant, + bool update = false, + }) async { if (update) { - return await consultantService.consultantUpdate(consultantModel: consultant); + return await consultantService.consultantUpdate( + consultantModel: consultant, + ); } else { - return await consultantService.consultantRegistration(consultantModel: consultant); + return await consultantService.consultantRegistration( + consultantModel: consultant, + ); } } /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. - Future getConsultantRequestStatus(String slug) async { + Future getConsultantRequestStatus( + String slug, + ) async { return await consultantService.getConsultantRequestStatus(slug: slug); } @@ -38,12 +47,18 @@ class ConsultantRepository { return await consultantService.getConsultantCertPhotos(); } - Future> storeConsultantCertPhotos({required List files}) async { + Future> storeConsultantCertPhotos({ + required List files, + }) async { final multipartFiles = []; for (final file in files) { final fileBytes = await file.readAsBytes(); - final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream')); + final multipartFile = MultipartFile.fromBytes( + fileBytes, + filename: file.path.split('/').last, + contentType: MediaType('application', 'octet-stream'), + ); multipartFiles.add(multipartFile); } @@ -51,15 +66,21 @@ class ConsultantRepository { return []; } - return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); + return await consultantService.storeConsultantCertPhotos( + photos: multipartFiles, + ); } Future hideConsultantCertPhoto(int id) async { - await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); + await consultantService.hideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future unHideConsultantCertPhoto(int id) async { - await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); + await consultantService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future deletePhoto(int id) async { diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 6482c667..8458824d 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -12,24 +12,35 @@ abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; @POST('/api/v2/consultant_request/registration') - Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); + Future consultantRegistration({ + @Body() required ConsultantProfileModel consultantModel, + }); @POST('/api/consultant_request/photos/upload') @MultiPart() - Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + Future> uploadPhotos({ + @Part(name: 'photos[]', contentType: 'multipart/form-data') + List? photos, + }); /// Публичный статус консультанта для просмотра профиля. /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') - Future getConsultantRequestStatus({@Path() required String slug}); + Future getConsultantRequestStatus({ + @Path() required String slug, + }); /// Свой статус заявки для редактирования/модерации. /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. @GET('/api/v2/consultant_request/{slug}/status') - Future getMyRequestStatus({@Path() required String slug}); + Future getMyRequestStatus({ + @Path() required String slug, + }); @PUT('/api/v2/consultant_request/update') - Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + Future consultantUpdate({ + @Body() required ConsultantProfileModel consultantModel, + }); @GET('/api/finances/sbp_banks') Future> getSbpBanks(); @@ -40,14 +51,19 @@ abstract class ConsultantService { @POST('/api/v2/consultant_request/cert_photos/store') @MultiPart() Future> storeConsultantCertPhotos({ - @Part(name: 'photos[]', contentType: 'multipart/form-data') required List photos, + @Part(name: 'photos[]', contentType: 'multipart/form-data') + required List photos, }); @POST('/api/v2/consultant_request/cert_photos/hide') - Future hideConsultantCertPhoto({@Body() required Map payload}); + Future hideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/un_hide') - Future unHideConsultantCertPhoto({@Body() required Map payload}); + Future unHideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') Future deletePhoto({@Path() required int id}); diff --git a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart new file mode 100644 index 00000000..d4b0d947 --- /dev/null +++ b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart @@ -0,0 +1,167 @@ +import 'package:dio/dio.dart'; + +/// Parsed Laravel 422 validation payload for consultant registration UI. +class LaravelValidationErrors { + const LaravelValidationErrors({ + required this.summary, + this.fieldErrors = const {}, + }); + + final String summary; + final Map fieldErrors; + + static const LaravelValidationErrors empty = LaravelValidationErrors( + summary: '', + ); + + bool get hasFieldErrors => fieldErrors.isNotEmpty; +} + +const _genericValidationSummary = 'Проверьте введённые данные'; +const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; + +/// Parses Laravel `errors` map and maps them to user-facing Russian texts. +/// Raw backend messages are never shown in the UI. +LaravelValidationErrors parseLaravelValidationErrors(Object error) { + if (error is! DioException) { + return const LaravelValidationErrors(summary: _genericNetworkSummary); + } + + final data = error.response?.data; + if (error.response?.statusCode == 422 && data is Map) { + final fieldErrors = {}; + final errors = data['errors']; + + if (errors is Map) { + errors.forEach((key, value) { + final fieldKey = key.toString(); + final raw = _rawErrorText(value); + fieldErrors[fieldKey] = mapConsultantValidationMessage( + fieldKey: fieldKey, + rawMessage: raw, + ); + }); + } + + if (fieldErrors.isNotEmpty) { + final uniqueMessages = fieldErrors.values.toSet().toList(); + return LaravelValidationErrors( + summary: uniqueMessages.join('\n'), + fieldErrors: fieldErrors, + ); + } + + return const LaravelValidationErrors(summary: _genericValidationSummary); + } + + return const LaravelValidationErrors(summary: _genericNetworkSummary); +} + +String _rawErrorText(Object? value) { + if (value is List && value.isNotEmpty) { + return value.map((e) => e.toString()).join(' '); + } + if (value is String) { + return value; + } + return ''; +} + +/// Maps a backend validation message for [fieldKey] to a short Russian UI string. +String mapConsultantValidationMessage({ + required String fieldKey, + required String rawMessage, +}) { + final field = fieldKey.split('.').last.toLowerCase(); + final raw = rawMessage.toLowerCase(); + + final isUnique = + raw.contains('taken') || + raw.contains('unique') || + raw.contains('already') || + raw.contains('уже существ') || + raw.contains('уже занят'); + final isRequired = + raw.contains('required') || + raw.contains('обязательн') || + raw.contains('required_if'); + final isAccepted = + raw.contains('accepted') || field.contains('agreement'); + final isUrl = raw.contains('url') || field.contains('link'); + final isFormat = + raw.contains('format') || + raw.contains('invalid') || + raw.contains('regex') || + raw.contains('digits') || + raw.contains('size') || + raw.contains('date') || + raw.contains('before'); + + if (isAccepted) { + if (field.contains('frdo')) { + return 'Подтвердите согласие ФРДО'; + } + return 'Примите условия оферты'; + } + + if (isUnique) { + return switch (field) { + 'inn' => 'Такой ИНН существует', + 'snils' => 'Такой СНИЛС существует', + 'passport_number' || 'passport_series' => 'Такой паспорт существует', + 'sbp_phone' => 'Такой телефон СБП существует', + _ => 'Такие данные уже существуют', + }; + } + + if (isRequired) { + return switch (field) { + 'inn' => 'Укажите ИНН', + 'snils' => 'Укажите СНИЛС', + 'last_name' => 'Укажите фамилию', + 'first_name' => 'Укажите имя', + 'middle_name' => 'Укажите отчество', + 'passport_series' => 'Укажите серию паспорта', + 'passport_number' => 'Укажите номер паспорта', + 'passport_issue_date' => 'Укажите дату выдачи паспорта', + 'passport_issuer' => 'Укажите, кем выдан паспорт', + 'passport_issuer_code' => 'Укажите код подразделения', + 'birth_date' => 'Укажите дату рождения', + 'birth_place' => 'Укажите место рождения', + 'registration_address' => 'Укажите адрес регистрации', + 'residence_address' => 'Укажите адрес проживания', + 'citizenship' => 'Укажите гражданство', + 'sbp_phone' => 'Укажите телефон СБП', + 'sbp_bank_member_id' => 'Выберите банк СБП', + 'completed_courses' => 'Добавьте хотя бы один курс', + 'title' => 'Укажите название курса', + 'date' => 'Укажите дату курса', + 'agreement_accepted' => 'Примите условия оферты', + 'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО', + _ => 'Заполните поле', + }; + } + + if (isUrl) { + return 'Некорректная ссылка'; + } + + if (isFormat || raw.isNotEmpty) { + return switch (field) { + 'inn' => 'Некорректный ИНН', + 'snils' => 'Некорректный СНИЛС', + 'passport_series' => 'Некорректная серия паспорта', + 'passport_number' => 'Некорректный номер паспорта', + 'passport_issue_date' => 'Некорректная дата выдачи', + 'passport_issuer_code' => 'Некорректный код подразделения', + 'birth_date' => 'Некорректная дата рождения', + 'citizenship' => 'Некорректный код гражданства', + 'sbp_phone' => 'Некорректный телефон СБП', + 'title' => 'Слишком короткое название курса', + 'date' => 'Некорректная дата курса', + _ => 'Некорректное значение', + }; + } + + return 'Проверьте поле'; +} diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index db230482..a1b0acab 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -238,7 +238,10 @@ class _SettingsScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), child: InkWell( - onTap: () => GoRouter.of(context).pushNamed('becomeConsultantScreen'), + onTap: () => GoRouter.of(context).pushNamed( + 'becomeConsultantScreen', + extra: {'slug': state.profile.slug}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, diff --git a/test/features/consultant/become_consultant/entities/diploma_entity_test.dart b/test/features/consultant/become_consultant/entities/diploma_entity_test.dart index e0f0e80b..3604a737 100644 --- a/test/features/consultant/become_consultant/entities/diploma_entity_test.dart +++ b/test/features/consultant/become_consultant/entities/diploma_entity_test.dart @@ -33,4 +33,3 @@ void main() { }); }); } - diff --git a/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart b/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart index 19292196..b1a832c6 100644 --- a/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart @@ -100,4 +100,3 @@ void main() { }); }); } - diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index fb88c9c5..12676d48 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -63,7 +63,10 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = CertificatesModel(title: 'Certificate Title', date: '2024-01-15'); + const model = CertificatesModel( + title: 'Certificate Title', + date: '2024-01-15', + ); final json = model.toJson(); @@ -105,7 +108,10 @@ void main() { test('toJson serializes all fields correctly', () { const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); - const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); + const certificates = CertificatesModel( + title: 'Course 1', + date: '2024-01-01', + ); const model = ConsultantProfileModel( hasMedicalEducation: true, socialLinks: socialLinks, diff --git a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart index f11423e4..11c95acb 100644 --- a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -46,7 +47,11 @@ void main() { }); test('fromJson handles null additional field', () { - final json = {'title': 'Test title', 'status': 'canceled', 'additional': null}; + final json = { + 'title': 'Test title', + 'status': 'canceled', + 'additional': null, + }; final model = CanceledModerationModel.fromJson(json); @@ -56,7 +61,11 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = CanceledModerationModel(title: 'Test title', status: ModerationStatus.canceled, additional: ['reason1', 'reason2']); + const model = CanceledModerationModel( + title: 'Test title', + status: ModerationStatus.canceled, + additional: ['reason1', 'reason2'], + ); final json = model.toJson(); @@ -133,7 +142,7 @@ void main() { 'inn': '123456789012', 'snils': '123-456-789 00', 'state': 'created', - 'alfa_scoring_status': 'pending', + 'alfa_scoring_status': 'scoring_failed', 'alfa_scoring_request_id': 'abc', 'passport_series': '1234', 'passport_number': '567890', @@ -154,19 +163,35 @@ void main() { expect(model.legalEntity!.inn, '123456789012'); expect(model.legalEntity!.passportSeries, '1234'); expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); - expect(model.legalEntity!.alfaScoringStatus, 'pending'); + expect( + model.legalEntity!.alfaScoringStatus, + AlfaScoringStatus.scoringFailed, + ); }); test('normalizeApiDate strips time from DateTime.toString format', () { - expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22'); + expect( + LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), + '2023-09-22', + ); expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); expect(LegalEntityModel.normalizeApiDate(null), isNull); }); test('toJson serializes ConsultationRequestModel correctly', () { - const educationModel = EducationModel(requestState: ModerationStatus.approved); - const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; - const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null); + const educationModel = EducationModel( + requestState: ModerationStatus.approved, + ); + const photos = [ + CertPhotosModel(url: 'https://example.com/photo1.jpg'), + CertPhotosModel(url: 'https://example.com/photo2.jpg'), + ]; + const model = ConsultationRequestModel( + ratingPlace: 5, + educationModel: educationModel, + photos: photos, + canceledModeration: null, + ); final json = model.toJson(); diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart index 48a90b13..beb67773 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart @@ -12,7 +12,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, - isUpdating: false + isUpdating: false, ); expect(state.consultant, consultant); @@ -20,6 +20,8 @@ void main() { expect(state.loading, false); expect(state.requestModel, isNull); expect(state.certPhotos, isEmpty); + expect(state.awaitingScoringOutcome, isFalse); + expect(state.fieldErrors, isEmpty); }); test('creates instance with custom values', () { @@ -38,7 +40,7 @@ void main() { loading: true, requestModel: null, certPhotos: [certPhoto], - isUpdating: false + isUpdating: false, ); expect(state.acceptOffer, true); @@ -54,7 +56,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, - isUpdating: false + isUpdating: false, ); final updatedState = originalState.copyWith( @@ -92,7 +94,7 @@ void main() { loading: false, requestModel: null, certPhotos: [certPhoto1], - isUpdating: false + isUpdating: false, ); final updatedState = originalState.copyWith( @@ -100,9 +102,14 @@ void main() { ); expect(updatedState.certPhotos.length, 2); - expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg'); - expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg'); + expect( + updatedState.certPhotos.first.url, + 'https://example.com/photo1.jpg', + ); + expect( + updatedState.certPhotos.last.url, + 'https://example.com/photo2.jpg', + ); }); }); } - diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 9eeeb304..6f6a0090 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -27,18 +29,47 @@ void main() { late List sbpBanksFromFixture; late ConsultantProfileModel consultantProfile; + ConsultationRequestModel requestWithScoring(String scoringStatus) { + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); + final legal = Map.from( + json['legal_entity_resource'] as Map, + ); + legal['alfa_scoring_status'] = scoringStatus; + json['legal_entity_resource'] = legal; + return ConsultationRequestModel.fromJson(json); + } + setUp(() { repository = MockConsultantRepository(); - cubit = RegisterConsultantCubit(consultantRepository: repository); - requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); - certPhotosFromFixture = FixtureReader.readAsList( - 'consultant/consultant_cert_photos.json', - ).map((item) => ConsultantCertPhotoModel.fromJson(item as Map)).toList(); - sbpBanksFromFixture = FixtureReader.readAsList( - 'consultant/sbp_banks.json', - ).map((item) => SbpBankModel.fromJson(item as Map)).toList(); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); - when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); + cubit = RegisterConsultantCubit( + consultantRepository: repository, + scoringPollInterval: const Duration(milliseconds: 30), + ); + requestModel = ConsultationRequestModel.fromJson( + FixtureReader.readAsMap('consultant/consultation_request.json'), + ); + certPhotosFromFixture = + FixtureReader.readAsList('consultant/consultant_cert_photos.json') + .map( + (item) => ConsultantCertPhotoModel.fromJson( + item as Map, + ), + ) + .toList(); + sbpBanksFromFixture = + FixtureReader.readAsList('consultant/sbp_banks.json') + .map( + (item) => SbpBankModel.fromJson(item as Map), + ) + .toList(); + consultantProfile = ConsultantProfileModel.fromJson( + FixtureReader.readAsMap('consultant/consultant_profile_request.json'), + ); + when( + () => repository.getSbpBanks(), + ).thenAnswer((_) async => sbpBanksFromFixture); }); tearDown(() async { @@ -50,11 +81,16 @@ void main() { expect(cubit.state.consultant.hasMedicalEducation, isTrue); expect(cubit.state.acceptOffer, isFalse); expect(cubit.state.requestModel, isNull); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none); }); test('getMyRequestStatus hydrates state with backend data', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.getMyRequestStatus('my-consultant-slug'); @@ -62,41 +98,90 @@ void main() { expect(state.loading, isFalse); expect(state.requestModel, equals(requestModel)); expect(state.consultant.certificates, isNotNull); - // Certificates list is built from backend courses; we just ensure it is not empty. expect(state.consultant.certificates!.length, greaterThan(0)); expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); - expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); + expect( + state.consultant.socialLinks?.instaLink, + isNull, + reason: 'empty links are sanitized', + ); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); expect(state.consultant.legalType, 'individualPerson'); expect(state.consultant.inn, '123456789012'); expect(state.consultant.passportSeries, '1234'); expect(state.consultant.passportIssueDate, '2020-01-15'); expect(state.consultant.birthPlace, 'г. Москва'); - // All cert photos from backend are kept in state (visible + hidden). expect(state.certPhotos, hasLength(3)); - expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); + expect( + state.certPhotos.map((e) => e.id), + containsAll([101, 102, 103]), + ); expect(state.sbpBanks, hasLength(2)); expect(state.sbpBanks.first.bankName, 'Сбербанк'); - verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); + expect(state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + expect(state.alfaScoringStatus.canRetry, isTrue); + expect(state.isScoringPolling, isFalse); + expect( + state.awaitingScoringOutcome, + isFalse, + reason: 'hydrate must not arm success sheet', + ); + verify( + () => repository.getMyRequestStatus('my-consultant-slug'), + ).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getSbpBanks()).called(1); }); - test('disableLoading stops spinner and loads cert photos', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + test( + 'getMyRequestStatus with processing status starts scoring poll', + () async { + final pending = requestWithScoring('scoring_pending'); + final complete = requestWithScoring('scoring_complete'); + var calls = 0; + when(() => repository.getMyRequestStatus(any())).thenAnswer(( + _, + ) async { + calls++; + return calls == 1 ? pending : complete; + }); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); - await cubit.disableLoading(); + await cubit.getMyRequestStatus('my-consultant-slug'); + + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringPending); + expect(cubit.state.isScoringPolling, isTrue); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + }, + ); + + test('disableLoading stops spinner and loads cert photos', () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + + await cubit.disableLoading(slug: 'my-consultant-slug'); expect(cubit.state.loading, isFalse); - // disableLoading now keeps all photos from repository. expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.sbpBanks, hasLength(2)); + expect(cubit.userSlug, 'my-consultant-slug'); verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getSbpBanks()).called(1); }); test('searchSbpBanks filters banks by name', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); final results = cubit.searchSbpBanks('сбер'); @@ -106,7 +191,9 @@ void main() { }); test('findSbpBankById returns matching bank', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); final bank = cubit.findSbpBankById('100000000004'); @@ -114,42 +201,205 @@ void main() { expect(bank?.bankName, 'Т-Банк'); }); - test('updateConsultantProfileModel replaces consultant data and offer flag', () { - cubit.updateConsultantProfileModel(consultantProfile, true); + test( + 'updateConsultantProfileModel replaces consultant data and offer flag', + () { + cubit.updateConsultantProfileModel(consultantProfile, true); - expect(cubit.state.consultant, consultantProfile); - expect(cubit.state.acceptOffer, isTrue); - expect(cubit.state.loading, isFalse); - }); + expect(cubit.state.consultant, consultantProfile); + expect(cubit.state.acceptOffer, isTrue); + expect(cubit.state.loading, isFalse); + }, + ); - test('registerConsultant forwards payload with update=false by default', () async { - when( - () => repository.registerConsultant( - consultant: any(named: 'consultant'), - update: any(named: 'update'), - ), - ).thenAnswer((_) async {}); + test( + 'registerConsultant forwards payload with update=false by default', + () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); - cubit.updateConsultantProfileModel(consultantProfile); - cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); - await cubit.registerConsultant(); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); - final captured = verify( - () => repository.registerConsultant( - consultant: captureAny(named: 'consultant'), - update: false, - ), - ).captured.single as ConsultantProfileModel; + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + named: 'consultant', + ), + update: false, + ), + ).captured.single + as ConsultantProfileModel; - expect(captured.legalType, 'individualPerson'); - expect(captured.agreementAccepted, isTrue); - expect(captured.frdoAgreementAccepted, isTrue); - expect(captured.inn, isNotNull); - }); + expect(captured.legalType, 'individualPerson'); + expect(captured.agreementAccepted, isTrue); + expect(captured.frdoAgreementAccepted, isTrue); + expect(captured.inn, isNotNull); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect( + cubit.state.isScoringPolling, + isFalse, + reason: 'no slug for poll', + ); + expect( + cubit.state.awaitingScoringOutcome, + isFalse, + reason: 'cannot await scoring without slug', + ); + }, + ); + + test( + 'getMyRequestStatus with scoring_complete does not arm awaiting flag', + () async { + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + + await cubit.getMyRequestStatus('my-consultant-slug'); + + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.awaitingScoringOutcome, isFalse); + }, + ); + + test( + 'registerConsultant with slug starts scoring poll after submit', + () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.disableLoading(slug: 'my-consultant-slug'); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect(cubit.state.isScoringPolling, isTrue); + expect(cubit.state.awaitingScoringOutcome, isTrue); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + expect( + cubit.state.awaitingScoringOutcome, + isFalse, + reason: 'flag clears on terminal scoring for one-shot success sheet', + ); + }, + ); + + test( + 'scoring poll failed clears awaiting flag without leaving processing', + () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_failed')); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.disableLoading(slug: 'my-consultant-slug'); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + expect(cubit.state.isScoringPolling, isFalse); + expect(cubit.state.awaitingScoringOutcome, isFalse); + expect(cubit.state.alfaScoringStatus.canRetry, isTrue); + }, + ); + + test( + 'scoring poll keeps processing UI while API still returns none', + () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + var calls = 0; + when(() => repository.getMyRequestStatus(any())).thenAnswer(( + _, + ) async { + calls++; + if (calls <= 2) { + return requestWithScoring('none'); + } + return requestWithScoring('scoring_complete'); + }); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.disableLoading(slug: 'my-consultant-slug'); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect(cubit.state.isScoringPolling, isTrue); + + await Future.delayed(const Duration(milliseconds: 80)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + }, + ); test('registerConsultant sends legal payload with update=true', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); when( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -160,12 +410,16 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - final captured = verify( - () => repository.registerConsultant( - consultant: captureAny(named: 'consultant'), - update: true, - ), - ).captured.single as ConsultantProfileModel; + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + named: 'consultant', + ), + update: true, + ), + ).captured.single + as ConsultantProfileModel; final json = captured.toJson(); expect(json['legal_type'], 'individualPerson'); @@ -175,6 +429,40 @@ void main() { expect(json.containsKey('frdo_agreement_accepted'), isFalse); }); + test('retryScoring calls update and restarts poll', () async { + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.getMyRequestStatus('my-consultant-slug'); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + await cubit.retryScoring(); + + verify( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: true, + ), + ).called(1); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringComplete); + expect(cubit.state.isScoringPolling, isFalse); + }); + test('registerConsultant blocks submit without passport data', () async { when( () => repository.registerConsultant( @@ -194,18 +482,59 @@ void main() { ); }); + test('registerConsultant maps Laravel 422 into fieldErrors', () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions( + path: '/api/v2/consultant_request/registration', + ), + response: Response( + requestOptions: RequestOptions( + path: '/api/v2/consultant_request/registration', + ), + statusCode: 422, + data: { + 'message': 'The given data was invalid.', + 'errors': { + 'inn': ['The inn format is invalid.'], + }, + }, + ), + type: DioExceptionType.badResponse, + ), + ); + + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + expect(cubit.state.fieldErrors['inn'], 'Некорректный ИНН'); + expect(cubit.state.error, contains('Некорректный ИНН')); + expect(cubit.state.awaitingScoringOutcome, isFalse); + expect(cubit.state.isScoringPolling, isFalse); + }); + test('removeCertPhoto marks cert as deleted locally', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); - when(() => repository.hideConsultantCertPhoto(any())).thenAnswer((_) async {}); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.hideConsultantCertPhoto(any()), + ).thenAnswer((_) async {}); await cubit.getMyRequestStatus('my-consultant-slug'); - // All photos from backend are present in state. expect(cubit.state.certPhotos, hasLength(3)); await cubit.removeCertPhoto(101, null); - // Current implementation only tracks deleted IDs; photos list remains unchanged here. verifyNever(() => repository.hideConsultantCertPhoto(any())); expect(cubit.state.deletedPhotoIds, contains(101)); expect(cubit.state.certPhotos, hasLength(3)); @@ -214,7 +543,9 @@ void main() { test('uploadCertPhotos - не загружает если список пуст', () async { await cubit.addCertPhotos([]); - verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); + verifyNever( + () => repository.storeConsultantCertPhotos(files: any(named: 'files')), + ); }); test('updateConsultantProfileModel - обновляет без acceptOffer', () { @@ -225,4 +556,38 @@ void main() { expect(cubit.state.loading, isFalse); }); }); + + group('AlfaScoringStatus', () { + test('fromString maps known values', () { + expect( + AlfaScoringStatus.fromString('beneficiary_creating'), + AlfaScoringStatus.beneficiaryCreating, + ); + expect( + AlfaScoringStatus.fromString('scoring_pending'), + AlfaScoringStatus.scoringPending, + ); + expect( + AlfaScoringStatus.fromString('scoring_complete'), + AlfaScoringStatus.scoringComplete, + ); + expect( + AlfaScoringStatus.fromString('scoring_failed'), + AlfaScoringStatus.scoringFailed, + ); + expect( + AlfaScoringStatus.fromString('beneficiary_failed'), + AlfaScoringStatus.beneficiaryFailed, + ); + expect(AlfaScoringStatus.fromString(null), AlfaScoringStatus.none); + expect(AlfaScoringStatus.fromString('unknown'), AlfaScoringStatus.none); + }); + + test('helpers', () { + expect(AlfaScoringStatus.scoringPending.isProcessing, isTrue); + expect(AlfaScoringStatus.scoringFailed.canRetry, isTrue); + expect(AlfaScoringStatus.scoringComplete.isComplete, isTrue); + expect(AlfaScoringStatus.scoringComplete.isTerminal, isTrue); + }); + }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart index 79425f7f..c4fc2eb9 100644 --- a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart @@ -18,15 +18,28 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } testWidgets('renders additional settings screen', (tester) async { final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: null, + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart index 8101e5eb..5288e170 100644 --- a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'certificates_and_diplomas_screen_smoke_test.mocks.dart'; void main() { group('CertificatesAndDiplomasScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -45,15 +51,30 @@ void main() { testWidgets('renders certificates and diplomas screen', (tester) async { final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: null, + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const CertificatesAndDiplomasScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -67,16 +88,28 @@ void main() { hasMedicalEducation: null, socialLinks: null, photos: null, - certificates: [CertificatesModel(title: 'Test Certificate', date: '2025-01-01')], + certificates: [ + CertificatesModel(title: 'Test Certificate', date: '2025-01-01'), + ], + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const CertificatesAndDiplomasScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index c95b21c1..800f7430 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -19,50 +19,78 @@ import 'last_step_screen_test.mocks.dart'; void main() { group('LastStepScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child, RegisterConsultantCubit cubit) { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } - testWidgets('renders last step screen with both agreements for new registration', (tester) async { - final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel( - hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), - photos: null, - certificates: null, - ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + testWidgets( + 'renders last step screen with both agreements for new registration', + (tester) async { + final cubit = MockRegisterConsultantCubit(); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); - when(cubit.state).thenReturn(state); - when(cubit.stream).thenAnswer((_) => Stream.value(state)); - when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); - await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); - await tester.pump(); + await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); + await tester.pump(); - expect(find.byType(LastStepScreen), findsOneWidget); - expect(find.text('Последний шаг'), findsOneWidget); - expect(find.byType(CheckboxListTile), findsNWidgets(2)); - expect(find.textContaining('ФРДО'), findsOneWidget); - }); + expect(find.byType(LastStepScreen), findsOneWidget); + expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.textContaining('ФРДО'), findsOneWidget); + }, + ); testWidgets('hides FRDO checkbox in update mode', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: true, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart index 3344e719..1b508e45 100644 --- a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -16,7 +16,9 @@ void main() { late RegisterConsultantCubit cubit; setUp(() { - cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); cubit.emit( const RegisterConsultantState( consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), @@ -35,7 +37,10 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } diff --git a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart index 94bdfe3b..5086cf86 100644 --- a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart @@ -19,12 +19,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart'; void main() { group('PhotoOrScansScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child, RegisterConsultantCubit cubit) { @@ -38,7 +44,10 @@ void main() { ], supportedLocales: const [Locale('ru')], home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } @@ -49,11 +58,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -61,7 +81,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(PhotoOrScansScreen), findsOneWidget); - expect(find.text('Загрузите фото или сканы дипломов и сертификатов'), findsOneWidget); + expect( + find.text('Загрузите фото или сканы дипломов и сертификатов'), + findsOneWidget, + ); }); }); @@ -71,11 +94,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart index d4818dd8..28d432f6 100644 --- a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart @@ -16,7 +16,9 @@ void main() { late RegisterConsultantCubit cubit; setUp(() { - cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); cubit.emit( const RegisterConsultantState( consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), @@ -35,7 +37,10 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } diff --git a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart index adddd454..7db759e2 100644 --- a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'social_networks_screen_smoke_test.mocks.dart'; void main() { group('SocialNetworksScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -47,18 +53,34 @@ void main() { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const SocialNetworksScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const SocialNetworksScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -69,18 +91,34 @@ void main() { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: 'https://vk.com/test', tgLink: 'https://t.me/test', fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: 'https://vk.com/test', + tgLink: 'https://t.me/test', + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const SocialNetworksScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const SocialNetworksScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 0f742c33..d663ea7e 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart'; void main() { group('StepperBecomeConsultantScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -48,11 +54,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -71,11 +88,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -94,11 +122,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -117,11 +156,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -140,11 +190,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -163,11 +224,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart index 468e638c..3ea68162 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -37,42 +37,99 @@ void main() { setUp(() { mockService = MockConsultantService(); repository = ConsultantRepository(consultantService: mockService); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); - consultationRequest = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); - certPhotos = FixtureReader.readAsList('consultant/consultant_cert_photos.json') - .map((item) => ConsultantCertPhotoModel.fromJson(item as Map)) - .toList(); + consultantProfile = ConsultantProfileModel.fromJson( + FixtureReader.readAsMap('consultant/consultant_profile_request.json'), + ); + consultationRequest = ConsultationRequestModel.fromJson( + FixtureReader.readAsMap('consultant/consultation_request.json'), + ); + certPhotos = + FixtureReader.readAsList('consultant/consultant_cert_photos.json') + .map( + (item) => ConsultantCertPhotoModel.fromJson( + item as Map, + ), + ) + .toList(); }); - test('registerConsultant delegates to consultantRegistration when update=false', () async { - when(() => mockService.consultantRegistration(consultantModel: any(named: 'consultantModel'))).thenAnswer((_) async {}); + test( + 'registerConsultant delegates to consultantRegistration when update=false', + () async { + when( + () => mockService.consultantRegistration( + consultantModel: any( + named: 'consultantModel', + ), + ), + ).thenAnswer((_) async {}); - await repository.registerConsultant(consultant: consultantProfile); + await repository.registerConsultant(consultant: consultantProfile); - verify(() => mockService.consultantRegistration(consultantModel: consultantProfile)).called(1); - verifyNever(() => mockService.consultantUpdate(consultantModel: any(named: 'consultantModel'))); - }); + verify( + () => mockService.consultantRegistration( + consultantModel: consultantProfile, + ), + ).called(1); + verifyNever( + () => mockService.consultantUpdate( + consultantModel: any( + named: 'consultantModel', + ), + ), + ); + }, + ); - test('registerConsultant delegates to consultantUpdate when update=true', () async { - when(() => mockService.consultantUpdate(consultantModel: any(named: 'consultantModel'))).thenAnswer((_) async {}); + test( + 'registerConsultant delegates to consultantUpdate when update=true', + () async { + when( + () => mockService.consultantUpdate( + consultantModel: any( + named: 'consultantModel', + ), + ), + ).thenAnswer((_) async {}); - await repository.registerConsultant(consultant: consultantProfile, update: true); + await repository.registerConsultant( + consultant: consultantProfile, + update: true, + ); - verify(() => mockService.consultantUpdate(consultantModel: consultantProfile)).called(1); - verifyNever(() => mockService.consultantRegistration(consultantModel: any(named: 'consultantModel'))); - }); + verify( + () => + mockService.consultantUpdate(consultantModel: consultantProfile), + ).called(1); + verifyNever( + () => mockService.consultantRegistration( + consultantModel: any( + named: 'consultantModel', + ), + ), + ); + }, + ); test('getConsultantRequestStatus returns request from service', () async { - when(() => mockService.getConsultantRequestStatus(slug: any(named: 'slug'))).thenAnswer((_) async => consultationRequest); + when( + () => mockService.getConsultantRequestStatus( + slug: any(named: 'slug'), + ), + ).thenAnswer((_) async => consultationRequest); final result = await repository.getConsultantRequestStatus('user-slug'); expect(result, equals(consultationRequest)); - verify(() => mockService.getConsultantRequestStatus(slug: 'user-slug')).called(1); + verify( + () => mockService.getConsultantRequestStatus(slug: 'user-slug'), + ).called(1); }); test('getMyRequestStatus returns request from service', () async { - when(() => mockService.getMyRequestStatus(slug: any(named: 'slug'))).thenAnswer((_) async => consultationRequest); + when( + () => mockService.getMyRequestStatus(slug: any(named: 'slug')), + ).thenAnswer((_) async => consultationRequest); final result = await repository.getMyRequestStatus('my-slug'); @@ -81,7 +138,9 @@ void main() { }); test('getConsultantCertPhotos returns photos from service', () async { - when(() => mockService.getConsultantCertPhotos()).thenAnswer((_) async => certPhotos); + when( + () => mockService.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotos); final result = await repository.getConsultantCertPhotos(); @@ -101,42 +160,80 @@ void main() { verify(() => mockService.getSbpBanks()).called(1); }); - test('storeConsultantCertPhotos returns empty list when no files provided', () async { - final result = await repository.storeConsultantCertPhotos(files: []); + test( + 'storeConsultantCertPhotos returns empty list when no files provided', + () async { + final result = await repository.storeConsultantCertPhotos(files: []); - expect(result, isEmpty); - verifyNever(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))); - }); + expect(result, isEmpty); + verifyNever( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ); + }, + ); - test('storeConsultantCertPhotos uploads prepared files and returns response', () async { - final mockFile = MockFile(); - when(() => mockFile.readAsBytes()).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); - when(() => mockFile.path).thenReturn('/tmp/certificate.png'); - when(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))).thenAnswer((_) async => certPhotos); + test( + 'storeConsultantCertPhotos uploads prepared files and returns response', + () async { + final mockFile = MockFile(); + when( + () => mockFile.readAsBytes(), + ).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); + when(() => mockFile.path).thenReturn('/tmp/certificate.png'); + when( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ).thenAnswer((_) async => certPhotos); - final result = await repository.storeConsultantCertPhotos(files: [mockFile]); + final result = await repository.storeConsultantCertPhotos( + files: [mockFile], + ); - expect(result, certPhotos); - final capturedArgument = verify(() => mockService.storeConsultantCertPhotos(photos: captureAny>(named: 'photos'))).captured.single as List; - expect(capturedArgument, hasLength(1)); - expect(capturedArgument.first.filename, 'certificate.png'); - }); + expect(result, certPhotos); + final capturedArgument = + verify( + () => mockService.storeConsultantCertPhotos( + photos: captureAny>(named: 'photos'), + ), + ).captured.single + as List; + expect(capturedArgument, hasLength(1)); + expect(capturedArgument.first.filename, 'certificate.png'); + }, + ); test('hideConsultantCertPhoto sends payload to service', () async { - when(() => mockService.hideConsultantCertPhoto(payload: any>(named: 'payload'))).thenAnswer((_) async {}); + when( + () => mockService.hideConsultantCertPhoto( + payload: any>(named: 'payload'), + ), + ).thenAnswer((_) async {}); await repository.hideConsultantCertPhoto(42); - verify(() => mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42})).called(1); + verify( + () => + mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42}), + ).called(1); }); test('unHideConsultantCertPhoto sends payload to service', () async { - when(() => mockService.unHideConsultantCertPhoto(payload: any>(named: 'payload'))).thenAnswer((_) async {}); + when( + () => mockService.unHideConsultantCertPhoto( + payload: any>(named: 'payload'), + ), + ).thenAnswer((_) async {}); await repository.unHideConsultantCertPhoto(7); - verify(() => mockService.unHideConsultantCertPhoto(payload: {'cert_photo_id': 7})).called(1); + verify( + () => mockService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': 7}, + ), + ).called(1); }); }); } - diff --git a/test/features/consultant/become_consultant/services/consultant_service_test.dart b/test/features/consultant/become_consultant/services/consultant_service_test.dart index dd429904..0ae3b63b 100644 --- a/test/features/consultant/become_consultant/services/consultant_service_test.dart +++ b/test/features/consultant/become_consultant/services/consultant_service_test.dart @@ -20,11 +20,14 @@ void main() { test('getMyRequestStatus - возвращает статус запроса', () async { const slug = 'test-consultant'; - final json = FixtureReader.readAsMap('consultant/consultation_request.json'); + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); final expectedRequest = ConsultationRequestModel.fromJson(json); - when(mockConsultantService.getMyRequestStatus(slug: slug)) - .thenAnswer((_) async => expectedRequest); + when( + mockConsultantService.getMyRequestStatus(slug: slug), + ).thenAnswer((_) async => expectedRequest); final result = await mockConsultantService.getMyRequestStatus(slug: slug); @@ -33,25 +36,36 @@ void main() { verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1); }); - test('getConsultantRequestStatus - возвращает статус запроса консультанта', () async { - const slug = 'test-consultant'; - final json = FixtureReader.readAsMap('consultant/consultation_request.json'); - final expectedRequest = ConsultationRequestModel.fromJson(json); + test( + 'getConsultantRequestStatus - возвращает статус запроса консультанта', + () async { + const slug = 'test-consultant'; + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); + final expectedRequest = ConsultationRequestModel.fromJson(json); - when(mockConsultantService.getConsultantRequestStatus(slug: slug)) - .thenAnswer((_) async => expectedRequest); + when( + mockConsultantService.getConsultantRequestStatus(slug: slug), + ).thenAnswer((_) async => expectedRequest); - final result = await mockConsultantService.getConsultantRequestStatus(slug: slug); + final result = await mockConsultantService.getConsultantRequestStatus( + slug: slug, + ); - expect(result, isA()); - verify(mockConsultantService.getConsultantRequestStatus(slug: slug)).called(1); - }); + expect(result, isA()); + verify( + mockConsultantService.getConsultantRequestStatus(slug: slug), + ).called(1); + }, + ); test('getConsultantCertPhotos - возвращает фото сертификатов', () async { final expectedPhotos = []; - when(mockConsultantService.getConsultantCertPhotos()) - .thenAnswer((_) async => expectedPhotos); + when( + mockConsultantService.getConsultantCertPhotos(), + ).thenAnswer((_) async => expectedPhotos); final result = await mockConsultantService.getConsultantCertPhotos(); @@ -60,16 +74,22 @@ void main() { }); test('consultantRegistration - регистрирует консультанта', () async { - final profileJson = FixtureReader.readAsMap('consultant/consultant_profile_request.json'); + final profileJson = FixtureReader.readAsMap( + 'consultant/consultant_profile_request.json', + ); final profile = ConsultantProfileModel.fromJson(profileJson); - when(mockConsultantService.consultantRegistration(consultantModel: profile)) - .thenAnswer((_) async => Future.value()); + when( + mockConsultantService.consultantRegistration(consultantModel: profile), + ).thenAnswer((_) async => Future.value()); - await mockConsultantService.consultantRegistration(consultantModel: profile); + await mockConsultantService.consultantRegistration( + consultantModel: profile, + ); - verify(mockConsultantService.consultantRegistration(consultantModel: profile)).called(1); + verify( + mockConsultantService.consultantRegistration(consultantModel: profile), + ).called(1); }); }); } - diff --git a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart new file mode 100644 index 00000000..4470e4e3 --- /dev/null +++ b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseLaravelValidationErrors', () { + DioException dio422(Map data) { + return DioException( + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + } + + test('maps unique and format errors to Russian UI texts', () { + final parsed = parseLaravelValidationErrors( + dio422({ + 'message': 'The given data was invalid.', + 'errors': { + 'inn': ['The inn format is invalid.'], + 'passport_number': ['The passport number has already been taken.'], + 'snils': ['The snils has already been taken.'], + }, + }), + ); + + expect(parsed.fieldErrors['inn'], 'Некорректный ИНН'); + expect(parsed.fieldErrors['passport_number'], 'Такой паспорт существует'); + expect(parsed.fieldErrors['snils'], 'Такой СНИЛС существует'); + expect(parsed.summary, isNot(contains('The given data was invalid.'))); + expect(parsed.summary, isNot(contains('already been taken'))); + expect(parsed.summary, contains('Некорректный ИНН')); + expect(parsed.summary, contains('Такой паспорт существует')); + expect(parsed.hasFieldErrors, isTrue); + }); + + test('maps required and agreement errors', () { + final parsed = parseLaravelValidationErrors( + dio422({ + 'errors': { + 'last_name': ['The last name field is required.'], + 'agreement_accepted': ['The agreement accepted must be accepted.'], + }, + }), + ); + + expect(parsed.fieldErrors['last_name'], 'Укажите фамилию'); + expect(parsed.fieldErrors['agreement_accepted'], 'Примите условия оферты'); + }); + + test('falls back to generic message for non-422 DioException', () { + final error = DioException( + requestOptions: RequestOptions(path: '/test'), + message: 'Network failed', + type: DioExceptionType.connectionError, + ); + + final parsed = parseLaravelValidationErrors(error); + + expect(parsed.summary, 'Не удалось отправить данные. Попробуйте ещё раз'); + expect(parsed.fieldErrors, isEmpty); + }); + + test('uses generic text for non-Dio errors', () { + final parsed = parseLaravelValidationErrors(Exception('boom')); + + expect(parsed.summary, 'Не удалось отправить данные. Попробуйте ещё раз'); + expect(parsed.fieldErrors, isEmpty); + }); + }); + + group('mapConsultantValidationMessage', () { + test('unique inn and snils', () { + expect( + mapConsultantValidationMessage( + fieldKey: 'inn', + rawMessage: 'The inn has already been taken.', + ), + 'Такой ИНН существует', + ); + expect( + mapConsultantValidationMessage( + fieldKey: 'snils', + rawMessage: 'The snils has already been taken.', + ), + 'Такой СНИЛС существует', + ); + }); + }); +} diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index bf7ae187..7d1fcc78 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -32,7 +32,7 @@ "inn": "123456789012", "snils": "123-456-789 00", "state": "created", - "alfa_scoring_status": "pending", + "alfa_scoring_status": "scoring_failed", "alfa_scoring_request_id": "abc-123", "passport_series": "1234", "passport_number": "567890",