diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 7cc78138..a97124f9 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -265,7 +265,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', @@ -273,7 +281,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 535b58fd..5d454ff2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -6,27 +6,100 @@ part 'consultant_profile_model.g.dart'; @freezed 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: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @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: '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: '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: '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: '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, }) = _ConsultantProfileModel; - factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + factory ConsultantProfileModel.fromJson(Map json) => + _$ConsultantProfileModelFromJson(json); + + bool get hasValidPassportData { + final last = lastName ?? ''; + final first = firstName ?? ''; + final innValue = inn ?? ''; + final snilsValue = snils ?? ''; + final series = passportSeries ?? ''; + final number = passportNumber ?? ''; + final issueDate = passportIssueDate ?? ''; + final issuer = passportIssuer ?? ''; + final issuerCode = passportIssuerCode ?? ''; + final birth = birthDate ?? ''; + final birthPlaceValue = birthPlace ?? ''; + final registration = registrationAddress ?? ''; + final residence = residenceAddress ?? ''; + final citizenshipValue = citizenship ?? ''; + + return last.trim().isNotEmpty && + first.trim().isNotEmpty && + RegExp(r'^\d{12}$').hasMatch(innValue) && + RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && + RegExp(r'^\d{4}$').hasMatch(series) && + RegExp(r'^\d{6}$').hasMatch(number) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && + issuer.trim().isNotEmpty && + RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && + birthPlaceValue.trim().isNotEmpty && + registration.trim().isNotEmpty && + residence.trim().isNotEmpty && + RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); + } } @freezed abstract class SocialLinksModel with _$SocialLinksModel { const factory SocialLinksModel({ - @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, - @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: 'vk_link', includeIfNull: false) String? vkLink, + @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, }) = _SocialLinksModel; - factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); + factory SocialLinksModel.fromJson(Map json) => + _$SocialLinksModelFromJson(json); } @freezed @@ -36,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 13cc0e77..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,26 +12,81 @@ 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 +abstract class LegalEntityModel with _$LegalEntityModel { + const LegalEntityModel._(); + + const factory LegalEntityModel({ + required int id, + required String type, + String? inn, + String? snils, + String? state, + @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, + @JsonKey(name: 'middle_name') String? middleName, + @JsonKey(name: 'passport_series') String? passportSeries, + @JsonKey(name: 'passport_number') String? passportNumber, + @JsonKey(name: 'passport_issue_date') String? passportIssueDate, + @JsonKey(name: 'passport_issuer') String? passportIssuer, + @JsonKey(name: 'passport_issuer_code') String? passportIssuerCode, + @JsonKey(name: 'birth_date') String? birthDate, + @JsonKey(name: 'birth_place') String? birthPlace, + @JsonKey(name: 'registration_address') String? registrationAddress, + @JsonKey(name: 'residence_address') String? residenceAddress, + String? citizenship, + @JsonKey(name: 'tax_resident_us') bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone') String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, + }) = _LegalEntityModel; + + 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) { + if (value == null || value.isEmpty) { + return null; + } + if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) { + return value.substring(0, 10); + } + return value; + } + + 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 { @@ -40,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 new file mode 100644 index 00000000..1ffb0c5f --- /dev/null +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sbp_bank_model.freezed.dart'; +part 'sbp_bank_model.g.dart'; + +@freezed +abstract class SbpBankModel with _$SbpBankModel { + const factory SbpBankModel({ + @JsonKey(name: 'bank_member_id') required String bankMemberId, + @JsonKey(name: 'bank_name') required String bankName, + @JsonKey(name: 'short_name') String? shortName, + @JsonKey(name: 'logo_url') String? logoUrl, + String? bic, + }) = _SbpBankModel; + + 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 0410ae04..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,42 +1,117 @@ +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; - RegisterConsultantCubit({required this.consultantRepository}) - : super( - const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), - loading: true, - acceptOffer: false, - isUpdating: false, - ), - ); + static const String individualPersonLegalType = 'individualPerson'; + + 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 { + return await consultantRepository.getSbpBanks(); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + return const []; + } + } + + List searchSbpBanks(String query) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return state.sbpBanks; + } + return state.sbpBanks.where((bank) { + 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); + }).toList(); + } + + SbpBankModel? findSbpBankById(String? bankMemberId) { + if (bankMemberId == null || bankMemberId.isEmpty) { + return null; + } + for (final bank in state.sbpBanks) { + if (bank.bankMemberId == bankMemberId) { + return bank; + } + } + return null; + } 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( consultant: ConsultantProfileModel( @@ -46,33 +121,107 @@ 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), + middleName: _emptyToNull(legal?.middleName), + inn: _emptyToNull(legal?.inn), + snils: _emptyToNull(legal?.snils), + passportSeries: _emptyToNull(legal?.passportSeries), + passportNumber: _emptyToNull(legal?.passportNumber), + passportIssueDate: legal?.normalizedPassportIssueDate, + passportIssuer: _emptyToNull(legal?.passportIssuer), + passportIssuerCode: _emptyToNull(legal?.passportIssuerCode), + birthDate: legal?.normalizedBirthDate, + birthPlace: _emptyToNull(legal?.birthPlace), + registrationAddress: _emptyToNull(legal?.registrationAddress), + residenceAddress: _emptyToNull(legal?.residenceAddress), + citizenship: _emptyToNull(legal?.citizenship), + taxResidentUs: legal?.taxResidentUs ?? false, + foreignTaxResident: legal?.foreignTaxResident ?? false, + sbpPhone: _emptyToNull(legal?.sbpPhone), + sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId), ), acceptOffer: false, + acceptFrdoAgreement: false, loading: false, requestModel: myRequestStatus, 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()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); + } + } + + String? _emptyToNull(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + return value.trim(); + } + + Future disableLoading({String? slug}) async { + try { + setUserSlug(slug); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); + final sbpBanks = await _loadSbpBanks(); + emit( + state.copyWith( + loading: false, + certPhotos: certPhotos, + sbpBanks: sbpBanks, + error: null, + fieldErrors: const {}, + awaitingScoringOutcome: false, + consultant: state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship, + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + ), + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); - } - } - - Future disableLoading() async { - try { - final certPhotos = await consultantRepository.getConsultantCertPhotos(); - emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null)); - } 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, + ), + ); } } @@ -82,41 +231,325 @@ class RegisterConsultantCubit extends Cubit { if (slug != null) { await getMyRequestStatus(slug); } else { - await disableLoading(); + await disableLoading(slug: _userSlug); } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { - emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); + void updateConsultantProfileModel( + ConsultantProfileModel consultant, [ + bool? acceptOffer, + bool? acceptFrdoAgreement, + ]) { + emit( + state.copyWith( + consultant: consultant, + acceptOffer: acceptOffer ?? state.acceptOffer, + 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, + ), + ); + } + + bool get hasAcceptedRequiredAgreements { + if (_update) { + return state.acceptOffer; + } + return state.acceptOffer && state.acceptFrdoAgreement; + } + + ConsultantProfileModel buildSubmitPayload() { + final withLegalType = state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship, + 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, + ), + ) + .toList(), + ); + + if (_update) { + return ConsultantProfileModel( + hasMedicalEducation: withLegalType.hasMedicalEducation, + socialLinks: withLegalType.socialLinks, + photos: withLegalType.photos, + certificates: withLegalType.certificates, + legalType: withLegalType.legalType, + lastName: withLegalType.lastName, + firstName: withLegalType.firstName, + middleName: withLegalType.middleName, + inn: withLegalType.inn, + snils: withLegalType.snils, + passportSeries: withLegalType.passportSeries, + passportNumber: withLegalType.passportNumber, + passportIssueDate: withLegalType.passportIssueDate, + passportIssuer: withLegalType.passportIssuer, + passportIssuerCode: withLegalType.passportIssuerCode, + birthDate: withLegalType.birthDate, + birthPlace: withLegalType.birthPlace, + registrationAddress: withLegalType.registrationAddress, + residenceAddress: withLegalType.residenceAddress, + citizenship: withLegalType.citizenship, + taxResidentUs: withLegalType.taxResidentUs, + foreignTaxResident: withLegalType.foreignTaxResident, + sbpPhone: withLegalType.sbpPhone, + sbpBankMemberId: withLegalType.sbpBankMemberId, + ); + } + + 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.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: state.consultant, 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()); @@ -130,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 e9635eb6..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,8 +1,10 @@ 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'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'register_consultant_state.freezed.dart'; @@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { const factory RegisterConsultantState({ required ConsultantProfileModel consultant, required bool acceptOffer, + @Default(false) bool acceptFrdoAgreement, 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 b2b26c0e..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 @@ -7,15 +7,18 @@ import 'package:flutter/material.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; 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 = [ @@ -29,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), @@ -70,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), @@ -93,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.toString())).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 734387f1..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,11 @@ 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'; import 'package:url_launcher/url_launcher.dart'; @@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget { } class _LastStepScreenState extends State { - bool checkboxState = false; + static const _frdoUrl = + 'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/'; + + late final TapGestureRecognizer _frdoTapRecognizer; + + @override + void initState() { + super.initState(); + _frdoTapRecognizer = TapGestureRecognizer() + ..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + } + + @override + void dispose() { + _frdoTapRecognizer.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final cubit = context.watch(); + final state = cubit.state; + return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 80, top: 16), child: Column( @@ -27,34 +49,138 @@ 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( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0x33FFCC00), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.requestModel!.canceledModeration!.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + 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), + ), + ], + ], + ], + ), + ), + ), + ], const SizedBox(height: 16), CheckboxListTile( - value: checkboxState, + value: state.acceptOffer, title: Text( hidePlans ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' - : 'Я ознакомлен и принимаю договор публичной офёрты', - style: const TextStyle(fontSize: 18, height: 1), + : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности', + style: const TextStyle(fontSize: 18, height: 1.2), ), - onChanged: (_) { - setState(() { - checkboxState = !checkboxState; - }); - context.read().updateConsultantProfileModel(context.read().state.consultant, checkboxState); + onChanged: (value) { + cubit.updateAgreements(acceptOffer: value ?? false); }, activeColor: appTheme.blueA400, contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + if (!state.isUpdating) ...[ + CheckboxListTile( + value: state.acceptFrdoAgreement, + title: Text.rich( + TextSpan( + style: const TextStyle( + fontSize: 18, + height: 1.2, + color: Color(0xFF323F4B), + ), + children: [ + const TextSpan( + text: + 'Обязательное требование: Один из моих документов об образовании есть в реестре ', + ), + TextSpan( + text: 'ФРДО', + style: TextStyle( + color: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: _frdoTapRecognizer, + ), + ], + ), + ), + onChanged: (value) { + cubit.updateAgreements(acceptFrdoAgreement: value ?? false); + }, + activeColor: appTheme.blueA400, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), ), - 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')), + onPressed: () async => await launchUrl( + 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, + ), ), ), ), @@ -67,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 new file mode 100644 index 00000000..466f4418 --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -0,0 +1,591 @@ +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'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +class PassportDataScreen extends StatefulWidget { + const PassportDataScreen({super.key}); + + @override + State createState() => _PassportDataScreenState(); +} + +class _PassportDataScreenState extends State { + late final TextEditingController lastNameController; + late final TextEditingController firstNameController; + late final TextEditingController middleNameController; + late final TextEditingController innController; + late final TextEditingController snilsController; + late final TextEditingController seriesController; + late final TextEditingController numberController; + late final TextEditingController issueDateController; + late final TextEditingController issuerController; + late final TextEditingController issuerCodeController; + late final TextEditingController birthDateController; + late final TextEditingController birthPlaceController; + late final TextEditingController citizenshipController; + late final TextEditingController registrationAddressController; + late final TextEditingController residenceAddressController; + late final TextEditingController sbpPhoneController; + late final TextEditingController sbpBankController; + final sbpBankSuggestionsController = SuggestionsController(); + + bool taxResidentUs = false; + bool foreignTaxResident = false; + String? selectedSbpBankMemberId; + String? selectedSbpBankName; + + final snilsMask = MaskTextInputFormatter( + mask: '###-###-### ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskIssue = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskBirth = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final issuerCodeMask = MaskTextInputFormatter( + mask: '###-###', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final phoneMask = MaskTextInputFormatter( + mask: '+7 (###) ### ## ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + + @override + void initState() { + super.initState(); + final consultant = context.read().state.consultant; + lastNameController = TextEditingController(text: consultant.lastName ?? ''); + firstNameController = TextEditingController( + text: consultant.firstName ?? '', + ); + middleNameController = TextEditingController( + text: consultant.middleName ?? '', + ); + innController = TextEditingController(text: consultant.inn ?? ''); + snilsController = TextEditingController(text: consultant.snils ?? ''); + seriesController = TextEditingController( + text: consultant.passportSeries ?? '', + ); + numberController = TextEditingController( + text: consultant.passportNumber ?? '', + ); + issueDateController = TextEditingController( + text: _apiDateToUi(consultant.passportIssueDate), + ); + issuerController = TextEditingController( + text: consultant.passportIssuer ?? '', + ); + issuerCodeController = TextEditingController( + text: consultant.passportIssuerCode ?? '', + ); + birthDateController = TextEditingController( + text: _apiDateToUi(consultant.birthDate), + ); + birthPlaceController = TextEditingController( + text: consultant.birthPlace ?? '', + ); + citizenshipController = TextEditingController(text: consultant.citizenship); + registrationAddressController = TextEditingController( + text: consultant.registrationAddress ?? '', + ); + residenceAddressController = TextEditingController( + text: consultant.residenceAddress ?? '', + ); + sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? ''); + selectedSbpBankMemberId = consultant.sbpBankMemberId; + final selectedBank = context + .read() + .findSbpBankById(consultant.sbpBankMemberId); + selectedSbpBankName = selectedBank?.bankName; + sbpBankController = TextEditingController(text: selectedSbpBankName ?? ''); + taxResidentUs = consultant.taxResidentUs ?? false; + foreignTaxResident = consultant.foreignTaxResident ?? false; + } + + void _hydrateSbpBankNameIfNeeded() { + if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) { + return; + } + if (sbpBankController.text.trim().isNotEmpty) { + return; + } + final selectedBank = context + .read() + .findSbpBankById(selectedSbpBankMemberId); + if (selectedBank == null) { + return; + } + selectedSbpBankName = selectedBank.bankName; + sbpBankController.text = selectedBank.bankName; + } + + @override + void dispose() { + lastNameController.dispose(); + firstNameController.dispose(); + middleNameController.dispose(); + innController.dispose(); + snilsController.dispose(); + seriesController.dispose(); + numberController.dispose(); + issueDateController.dispose(); + issuerController.dispose(); + issuerCodeController.dispose(); + birthDateController.dispose(); + birthPlaceController.dispose(); + citizenshipController.dispose(); + registrationAddressController.dispose(); + residenceAddressController.dispose(); + sbpPhoneController.dispose(); + sbpBankController.dispose(); + sbpBankSuggestionsController.dispose(); + super.dispose(); + } + + String _apiDateToUi(String? apiDate) { + if (apiDate == null || apiDate.isEmpty) { + return ''; + } + final parts = apiDate.split('-'); + if (parts.length != 3) { + return apiDate; + } + return '${parts[2]}.${parts[1]}.${parts[0]}'; + } + + String? _uiDateToApi(String uiDate) { + final digits = uiDate.replaceAll(RegExp(r'\D'), ''); + if (digits.length != 8) { + return null; + } + return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}'; + } + + String? _uiPhoneToApi(String uiPhone) { + final digits = uiPhone.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) { + return null; + } + if (digits.length == 11 && digits.startsWith('7')) { + return '+$digits'; + } + if (digits.length == 10) { + return '+7$digits'; + } + return uiPhone.trim().isEmpty ? null : uiPhone.trim(); + } + + void _syncToCubit() { + final cubit = context.read(); + final current = cubit.state.consultant; + cubit.updateConsultantProfileModel( + current.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + lastName: lastNameController.text.trim().isEmpty + ? null + : lastNameController.text.trim(), + firstName: firstNameController.text.trim().isEmpty + ? null + : firstNameController.text.trim(), + middleName: middleNameController.text.trim().isEmpty + ? null + : middleNameController.text.trim(), + inn: innController.text.trim().isEmpty + ? null + : innController.text.trim(), + snils: snilsController.text.trim().isEmpty + ? null + : snilsController.text.trim(), + passportSeries: seriesController.text.trim().isEmpty + ? null + : seriesController.text.trim(), + passportNumber: numberController.text.trim().isEmpty + ? null + : numberController.text.trim(), + passportIssueDate: _uiDateToApi(issueDateController.text), + passportIssuer: issuerController.text.trim().isEmpty + ? null + : issuerController.text.trim(), + passportIssuerCode: issuerCodeController.text.trim().isEmpty + ? null + : issuerCodeController.text.trim(), + birthDate: _uiDateToApi(birthDateController.text), + birthPlace: birthPlaceController.text.trim().isEmpty + ? null + : birthPlaceController.text.trim(), + citizenship: citizenshipController.text.trim().isEmpty + ? 'RU' + : citizenshipController.text.trim().toUpperCase(), + registrationAddress: registrationAddressController.text.trim().isEmpty + ? null + : registrationAddressController.text.trim(), + residenceAddress: residenceAddressController.text.trim().isEmpty + ? null + : residenceAddressController.text.trim(), + taxResidentUs: taxResidentUs, + foreignTaxResident: foreignTaxResident, + sbpPhone: _uiPhoneToApi(sbpPhoneController.text), + sbpBankMemberId: selectedSbpBankMemberId, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + } + + void _selectSbpBank(SbpBankModel bank) { + setState(() { + selectedSbpBankMemberId = bank.bankMemberId; + selectedSbpBankName = bank.bankName; + sbpBankController.text = bank.bankName; + }); + _syncToCubit(); + } + + void _clearSbpBankIfNeeded(String value) { + if (selectedSbpBankMemberId != null && value != selectedSbpBankName) { + setState(() { + selectedSbpBankMemberId = null; + selectedSbpBankName = null; + }); + _syncToCubit(); + } + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, + listener: (context, state) => _hydrateSbpBankNameIfNeeded(), + 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, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PassportScreenTitle(title: 'Паспортные данные'), + const PassportSectionTitle( + title: 'Персональные данные (как в паспорте)', + ), + PassportFieldWithError( + error: errors['last_name'], + child: CustomTextFormField( + hintText: 'Фамилия', + controller: lastNameController, + hasError: errors.containsKey('last_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['first_name'], + child: CustomTextFormField( + 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, + ), + ), + ], + ), + ); + }, + ), + ); + } +} 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 b8fed20b..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 @@ -1,5 +1,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class RegistrationAsScreen extends StatefulWidget { const RegistrationAsScreen({super.key}); @@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = + RegistrationAsType.physical; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final cubit = context.read(); + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + }); + } @override Widget build(BuildContext context) { @@ -23,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( @@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: (_) {}, ), 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), + ), ), ), ], @@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), 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, + color: Colors.black38, + ), + ), ), ), ], @@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), 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, + color: Colors.black38, + ), + ), ), ), ], @@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), 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, + 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( + '* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся', + ), ), ], ), @@ -132,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 a27e5c67..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,11 +1,13 @@ 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'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; @@ -21,39 +23,145 @@ 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 { - double progressValue = 1 / 6; +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!); } } + static const int totalSteps = 7; + + List _pages() { + return const [ + CertificatesAndDiplomasScreen(), + PhotoOrScansScreen(), + AdditionalSettingsScreen(), + SocialNetworksScreen(), + RegistrationAsScreen(), + PassportDataScreen(), + LastStepScreen(), + ]; + } + + Future _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) { + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { return Scaffold( appBar: AppBar( centerTitle: true, @@ -72,248 +180,320 @@ class _StepperBecomeConsultantScreenState extends State context.read().reloadData(widget.slug), + onReload: () => context + .read() + .reloadData(widget.slug), ), ); } + + if (progressValue == 0) { + progressValue = 1 / totalSteps; + } + + final submitDisabled = + isLoading || + state.isScoringPolling || + state.scoringRetryInProgress; + return Scaffold( - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: Row( - children: [ - Container( - height: 48, - width: MediaQuery.of(context).size.width * 0.2, - padding: const EdgeInsets.only(left: 16, right: 4), - child: FloatingActionButton.extended( - heroTag: 'backButtonRegistration', - highlightElevation: 0, - focusElevation: 0, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: const Color(0xFFEAF2FD), - onPressed: () async { - setState(() { - pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); - }, - label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400), + floatingActionButtonLocation: + FloatingActionButtonLocation.centerFloat, + floatingActionButton: Row( + children: [ + Container( + height: 48, + width: MediaQuery.of(context).size.width * 0.2, + padding: const EdgeInsets.only(left: 16, right: 4), + child: FloatingActionButton.extended( + heroTag: 'backButtonRegistration', + highlightElevation: 0, + focusElevation: 0, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: const Color(0xFFEAF2FD), + onPressed: () async { + setState(() { + pageController.previousPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); + }, + label: Icon( + Icons.arrow_back_ios_new_rounded, + color: appTheme.blueA400, ), ), - if (widget.isCanceled == true) - InkWell( - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), + ), + if (widget.isCanceled == true && step != totalSteps) + InkWell( + onTap: () { + final canceledModeration = + state.requestModel?.canceledModeration; + if (canceledModeration == null) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(canceledModeration.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (canceledModeration.additional != null) + for (final item + in canceledModeration.additional!) ...[ + Text('• $item'), ], - ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ], ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: const Color(0x33FFCC00), + ), + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + height: 1, ), ), ), ), - Container( - height: 48, - width: widget.isCanceled == true - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: isLoading - ? Colors.grey - : widget.isCanceled == true - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: isLoading - ? null - : () async { - if (step == 6) { - if (widget.isCanceled == true) { - return; - } - if (registrationConsultantCubit.state.acceptOffer) { - setState(() { - isLoading = true; - }); - try { - 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), - ), - ), - ), - ), - ], - ), + ), + Container( + height: 48, + width: widget.isCanceled == true && step != totalSteps + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: submitDisabled + ? Colors.grey + : widget.isCanceled == true && step != totalSteps + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: submitDisabled + ? null + : () async { + if (step == totalSteps) { + if (!state.consultant.hasValidPassportData) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: const Text( + 'Заполните паспортные данные', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => + Navigator.of(context).pop(), ), - ); - } catch (e) { - if (!context.mounted) return; - showDialog( + ], + ), + ); + return; + } + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit + .registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit.state.error != + null) { + showDialog( context: context, builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: Text(e.toString()), + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text( + registrationConsultantCubit + .state + .error!, + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), ); - } finally { - setState(() { - isLoading = false; - }); + return; } - } else { - showDialog( + // 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), - content: const Text('Примите договор публичной офёрты'), + 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(), ), ], ), ); + } finally { + setState(() { + isLoading = false; + }); } } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); } - }, - label: isLoading - ? const CircularProgressIndicator() - : widget.isCanceled == true - ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) - : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), - ), + } else { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); + } + }, + label: submitDisabled && step == totalSteps + ? const CircularProgressIndicator() + : widget.isCanceled == true && step != totalSteps + ? Icon( + Icons.arrow_forward_ios_outlined, + color: appTheme.blueA400, + ) + : Text( + step == totalSteps ? 'Отправить' : 'Продолжить', + style: theme.textTheme.titleSmall!.copyWith( + color: Colors.white, + ), + ), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - 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 из 6'))], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween(begin: 1 / 6, end: progressValue), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + 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'), + ), + ], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + 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, ), ), ), ), ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / 6; - }); - }, - children: const [ - CertificatesAndDiplomasScreen(), - PhotoOrScansScreen(), - AdditionalSettingsScreen(), - SocialNetworksScreen(), - RegistrationAsScreen(), - LastStepScreen(), - ], - ), - ); + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); }, ), ); 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 c0e1c57a..4bc97dce 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.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'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:http_parser/http_parser.dart'; @@ -11,36 +12,53 @@ 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); - } + /// Своя заявка — education + legal/passport для регистрации и update. Future getMyRequestStatus(String slug) async { return await consultantService.getMyRequestStatus(slug: slug); + } + Future> getSbpBanks() async { + return await consultantService.getSbpBanks(); } Future> getConsultantCertPhotos() async { 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); } @@ -48,22 +66,24 @@ 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 { await consultantService.deletePhoto(id: id); - } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 9a0757ad..8458824d 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.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'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:retrofit/retrofit.dart'; part 'consultant_service.g.dart'; @@ -10,21 +11,39 @@ part 'consultant_service.g.dart'; abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; - @POST('/api/consultant_request/registration') - Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); + @POST('/api/v2/consultant_request/registration') + 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, + }); - @GET('/api/consultant_request/{slug}/status') - Future getMyRequestStatus({@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, + }); - @PUT('/api/consultant_request/update') - Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + @PUT('/api/v2/consultant_request/update') + Future consultantUpdate({ + @Body() required ConsultantProfileModel consultantModel, + }); + + @GET('/api/finances/sbp_banks') + Future> getSbpBanks(); @GET('/api/v2/consultant_request/cert_photos') Future> getConsultantCertPhotos(); @@ -32,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 3b008a3f..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,8 +108,21 @@ 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 model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]); + const certificates = CertificatesModel( + title: 'Course 1', + date: '2024-01-01', + ); + const model = ConsultantProfileModel( + hasMedicalEducation: true, + socialLinks: socialLinks, + photos: ['photo1.jpg'], + certificates: [certificates], + legalType: 'individualPerson', + inn: '123456789012', + snils: '123-456-789 00', + agreementAccepted: true, + frdoAgreementAccepted: true, + ); final json = model.toJson(); @@ -114,6 +130,34 @@ void main() { expect(json['social_links'], isNotNull); expect(json['cert_photos'], ['photo1.jpg']); expect(json['completed_courses'], isNotNull); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['snils'], '123-456-789 00'); + expect(json['agreement_accepted'], isTrue); + expect(json['frdo_agreement_accepted'], isTrue); + }); + + test('hasValidPassportData validates required individualPerson fields', () { + const valid = ConsultantProfileModel( + lastName: 'Иванов', + firstName: 'Иван', + inn: '123456789012', + snils: '123-456-789 00', + passportSeries: '1234', + passportNumber: '567890', + passportIssueDate: '2020-01-15', + passportIssuer: 'ОВД', + passportIssuerCode: '772-053', + birthDate: '1990-05-20', + birthPlace: 'Москва', + registrationAddress: 'Адрес 1', + residenceAddress: 'Адрес 2', + citizenship: 'RU', + ); + + expect(valid.hasValidPassportData, isTrue); + expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse); + expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse); }); }); } 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 7dd05d98..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(); @@ -122,10 +131,67 @@ void main() { expect(model.canceledModeration!.status, ModerationStatus.canceled); }); + test('fromJson parses legal_entity_resource', () { + final json = { + 'rating_place': 5, + 'education_resource': {'request_state': 'approved'}, + 'cert_photos': [], + 'legal_entity_resource': { + 'id': 1, + 'type': 'individualPerson', + 'inn': '123456789012', + 'snils': '123-456-789 00', + 'state': 'created', + 'alfa_scoring_status': 'scoring_failed', + 'alfa_scoring_request_id': 'abc', + 'passport_series': '1234', + 'passport_number': '567890', + 'passport_issue_date': '2020-01-15T00:00:00.000000Z', + 'passport_issuer': 'ОВД', + 'passport_issuer_code': '772-053', + 'birth_date': '1990-05-20', + 'birth_place': 'Москва', + 'registration_address': 'Адрес 1', + 'residence_address': 'Адрес 2', + }, + }; + + final model = ConsultationRequestModel.fromJson(json); + + expect(model.legalEntity, isNotNull); + expect(model.legalEntity!.type, 'individualPerson'); + expect(model.legalEntity!.inn, '123456789012'); + expect(model.legalEntity!.passportSeries, '1234'); + expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); + 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'), '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 a721050b..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,6 +1,9 @@ +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'; +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/repositories/consultant_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,16 +26,50 @@ void main() { late RegisterConsultantCubit cubit; late ConsultationRequestModel requestModel; late List certPhotosFromFixture; + 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(); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); + 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 { @@ -44,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'); @@ -56,53 +98,308 @@ 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'); - // All cert photos from backend are kept in state (visible + hidden). + 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, 'г. Москва'); expect(state.certPhotos, hasLength(3)); - expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); - verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); + expect( + state.certPhotos.map((e) => e.id), + containsAll([101, 102, 103]), + ); + expect(state.sbpBanks, hasLength(2)); + expect(state.sbpBanks.first.bankName, 'Сбербанк'); + 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( + '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.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); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(slug: 'my-consultant-slug'); + + expect(cubit.state.loading, isFalse); + 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); await cubit.disableLoading(); - expect(cubit.state.loading, isFalse); - // disableLoading now keeps all photos from repository. - expect(cubit.state.certPhotos, hasLength(3)); - verify(() => repository.getConsultantCertPhotos()).called(1); + final results = cubit.searchSbpBanks('сбер'); + + expect(results, hasLength(1)); + expect(results.first.bankMemberId, '100000000111'); }); - 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); - }); - - test('registerConsultant forwards payload with update=false by default', () async { + test('findSbpBankById returns matching bank', () async { when( - () => repository.registerConsultant( - consultant: any(named: 'consultant'), - update: any(named: 'update'), - ), - ).thenAnswer((_) async {}); + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); - await cubit.registerConsultant(); + final bank = cubit.findSbpBankById('100000000004'); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1); + expect(bank?.bankName, 'Т-Банк'); }); - test('registerConsultant sends update=true after getMyRequestStatus call', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + 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); + }, + ); + + 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(); + + 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( + 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.registerConsultant( consultant: any(named: 'consultant'), @@ -113,21 +410,131 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1); + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + named: 'consultant', + ), + update: true, + ), + ).captured.single + as ConsultantProfileModel; + + final json = captured.toJson(); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['passport_series'], '1234'); + expect(json.containsKey('agreement_accepted'), isFalse); + 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( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.registerConsultant(); + + expect(cubit.state.error, 'Заполните паспортные данные'); + verifyNever( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ); + }); + + 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)); @@ -136,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', () { @@ -147,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 cff449e8..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,43 +19,88 @@ 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', (tester) async { + 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()); + + 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); + }, + ); + + testWidgets('hides FRDO checkbox in update mode', (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 consultant = ConsultantProfileModel(); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: true, ); - 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, any)).thenReturn(null); await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); await tester.pump(); - expect(find.byType(LastStepScreen), findsOneWidget); - expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.textContaining('ФРДО'), findsNothing); }); }); } 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 new file mode 100644 index 00000000..1b508e45 --- /dev/null +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -0,0 +1,64 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_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/pages/passport_data_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} + +void main() { + group('PassportDataScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + + Widget wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: child, + ), + ), + ); + } + + testWidgets('renders passport data fields', (tester) async { + await tester.pumpWidget(wrap(const PassportDataScreen())); + await tester.pump(); + + expect(find.byType(PassportDataScreen), findsOneWidget); + expect(find.text('Паспортные данные'), findsOneWidget); + expect(find.text('Фамилия'), findsOneWidget); + expect(find.text('Имя'), findsOneWidget); + expect(find.text('ИНН (12 цифр)'), findsOneWidget); + expect(find.text('СНИЛС'), findsOneWidget); + expect(find.text('Гражданство (ISO, например RU)'), findsOneWidget); + expect(find.text('Телефон для выплат через СБП'), findsOneWidget); + expect(find.text('Банк получатель (поиск)'), findsOneWidget); + expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget); + }); + }); +} 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 df170daa..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 @@ -1,24 +1,59 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_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/pages/registration_as_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} void main() { group('RegistrationAsScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + Widget wrap(Widget child) { return MaterialApp( theme: ThemeHelper().themeData(), - home: Scaffold(body: child), + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: child, + ), + ), ); } testWidgets('renders registration as screen', (tester) async { await tester.pumpWidget(wrap(const RegistrationAsScreen())); await tester.pump(); + await tester.pump(); expect(find.byType(RegistrationAsScreen), findsOneWidget); expect(find.text('Регистрация в качестве'), findsOneWidget); expect(find.text('Физического лица'), findsOneWidget); + expect(cubit.state.consultant.legalType, 'individualPerson'); }); }); } 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 34c2db42..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); @@ -86,7 +114,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('шаг 1 из 6'), findsOneWidget); + expect(find.text('шаг 1 из 7'), findsOneWidget); }); testWidgets('displays progress bar', (tester) async { @@ -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 c695cc5b..3ea68162 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.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'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -36,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'); @@ -80,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(); @@ -88,42 +148,92 @@ void main() { verify(() => mockService.getConsultantCertPhotos()).called(1); }); - test('storeConsultantCertPhotos returns empty list when no files provided', () async { - final result = await repository.storeConsultantCertPhotos(files: []); + test('getSbpBanks returns banks from service', () async { + final banks = FixtureReader.readAsList('consultant/sbp_banks.json') + .map((item) => SbpBankModel.fromJson(item as Map)) + .toList(); + when(() => mockService.getSbpBanks()).thenAnswer((_) async => banks); - expect(result, isEmpty); - verifyNever(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))); + final result = await repository.getSbpBanks(); + + expect(result, banks); + verify(() => mockService.getSbpBanks()).called(1); }); - 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 returns empty list when no files provided', + () async { + final result = await repository.storeConsultantCertPhotos(files: []); - final result = await repository.storeConsultantCertPhotos(files: [mockFile]); + expect(result, isEmpty); + verifyNever( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ); + }, + ); - 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( + '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], + ); + + 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/consultant_profile_request.json b/test/fixtures/consultant/consultant_profile_request.json index fc05c731..270d4a20 100644 --- a/test/fixtures/consultant/consultant_profile_request.json +++ b/test/fixtures/consultant/consultant_profile_request.json @@ -20,6 +20,26 @@ "title": "Психология питания", "date": "2023-06-05" } - ] + ], + "legal_type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567", + "agreement_accepted": true, + "frdo_agreement_accepted": true } - diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index f4065e15..7d1fcc78 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -23,6 +23,31 @@ "personal_site_link": "https://consultant.eatvio.app", "request_state": "approved" }, + "legal_entity_resource": { + "id": 1, + "type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "state": "created", + "alfa_scoring_status": "scoring_failed", + "alfa_scoring_request_id": "abc-123", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567" + }, "cert_photos": [ { "url": "https://cdn.eatvio.app/certificates/visible.jpg" @@ -40,4 +65,3 @@ ] } } - diff --git a/test/fixtures/consultant/sbp_banks.json b/test/fixtures/consultant/sbp_banks.json new file mode 100644 index 00000000..b725de52 --- /dev/null +++ b/test/fixtures/consultant/sbp_banks.json @@ -0,0 +1,16 @@ +[ + { + "bank_member_id": "100000000111", + "bank_name": "Сбербанк", + "short_name": "Сбер", + "logo_url": "https://example.com/sber.png", + "bic": "044525225" + }, + { + "bank_member_id": "100000000004", + "bank_name": "Т-Банк", + "short_name": "ТБанк", + "logo_url": "https://example.com/tbank.png", + "bic": "044525974" + } +]