Merge pull request 'consultant_scoring' (#1011) from misha/eatvio_app:consultant_scoring into consultant_scoring

Reviewed-on: eatvio/eatvio_app#1011
This commit is contained in:
2026-07-31 10:45:21 +00:00
42 changed files with 2894 additions and 757 deletions

View File

@@ -265,7 +265,15 @@ class OtherRoutes {
), ),
// Consultant routes // 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<String, dynamic>?;
final slug = data?['slug'] as String?;
return BecomeConsultantScreen(slug: slug);
},
),
GoRoute( GoRoute(
path: RouteConstants.stepperBecomeConsultantScreen, path: RouteConstants.stepperBecomeConsultantScreen,
name: 'stepperBecomeConsultantScreen', name: 'stepperBecomeConsultantScreen',
@@ -273,7 +281,12 @@ class OtherRoutes {
final data = state.extra as Map<String, dynamic>; final data = state.extra as Map<String, dynamic>;
final slug = data['slug'] as String?; final slug = data['slug'] as String?;
final isCanceled = data['isCanceled'] as bool?; 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()), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),

View File

@@ -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;
}

View File

@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
const ConsultantCertPhotoModel._(); const ConsultantCertPhotoModel._();
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json); factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
_$ConsultantCertPhotoModelFromJson(json);
static const String visibleGroup = 'user_certs'; static const String visibleGroup = 'user_certs';
static const String hiddenGroup = 'hidden_user_certs'; static const String hiddenGroup = 'hidden_user_certs';

View File

@@ -9,35 +9,50 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel {
const ConsultantProfileModel._(); const ConsultantProfileModel._();
const factory ConsultantProfileModel({ const factory ConsultantProfileModel({
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, @JsonKey(name: 'has_medical_education', includeIfNull: false)
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, bool? hasMedicalEducation,
@JsonKey(name: 'social_links', includeIfNull: false)
SocialLinksModel? socialLinks,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos, @JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates, @JsonKey(name: 'completed_courses', includeIfNull: false)
List<CertificatesModel>? certificates,
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName, @JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName, @JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
@JsonKey(name: 'inn', includeIfNull: false) String? inn, @JsonKey(name: 'inn', includeIfNull: false) String? inn,
@JsonKey(name: 'snils', includeIfNull: false) String? snils, @JsonKey(name: 'snils', includeIfNull: false) String? snils,
@JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, @JsonKey(name: 'passport_series', includeIfNull: false)
@JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, String? passportSeries,
@JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, @JsonKey(name: 'passport_number', includeIfNull: false)
@JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, String? passportNumber,
@JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, @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_date', includeIfNull: false) String? birthDate,
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
@JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress, @JsonKey(name: 'registration_address', includeIfNull: false)
@JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress, String? registrationAddress,
@JsonKey(name: 'residence_address', includeIfNull: false)
String? residenceAddress,
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident, @JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
bool? foreignTaxResident,
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId, @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
@JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted, String? sbpBankMemberId,
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted, @JsonKey(name: 'agreement_accepted', includeIfNull: false)
bool? agreementAccepted,
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
bool? frdoAgreementAccepted,
}) = _ConsultantProfileModel; }) = _ConsultantProfileModel;
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json); factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
_$ConsultantProfileModelFromJson(json);
bool get hasValidPassportData { bool get hasValidPassportData {
final last = lastName ?? ''; final last = lastName ?? '';
@@ -79,10 +94,12 @@ abstract class SocialLinksModel with _$SocialLinksModel {
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, @JsonKey(name: 'personal_site_link', includeIfNull: false)
String? personalSiteLink,
}) = _SocialLinksModel; }) = _SocialLinksModel;
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json); factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
_$SocialLinksModelFromJson(json);
} }
@freezed @freezed
@@ -92,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel {
required String date, required String date,
}) = _CertificatesModel; }) = _CertificatesModel;
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json); factory CertificatesModel.fromJson(Map<String, Object?> json) =>
_$CertificatesModelFromJson(json);
} }

View File

@@ -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:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,11 +12,13 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
@JsonKey(name: 'rating_place') int? ratingPlace, @JsonKey(name: 'rating_place') int? ratingPlace,
@JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'education_resource') required EducationModel educationModel,
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos, @JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, @JsonKey(name: 'canceled_moderation')
CanceledModerationModel? canceledModeration,
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
}) = _ConsultationRequestModel; }) = _ConsultationRequestModel;
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json); factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
_$ConsultationRequestModelFromJson(json);
} }
@freezed @freezed
@@ -28,7 +31,13 @@ abstract class LegalEntityModel with _$LegalEntityModel {
String? inn, String? inn,
String? snils, String? snils,
String? state, String? state,
@JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, @JsonKey(
name: 'alfa_scoring_status',
unknownEnumValue: AlfaScoringStatus.none,
defaultValue: AlfaScoringStatus.none,
)
@Default(AlfaScoringStatus.none)
AlfaScoringStatus alfaScoringStatus,
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
@JsonKey(name: 'last_name') String? lastName, @JsonKey(name: 'last_name') String? lastName,
@JsonKey(name: 'first_name') String? firstName, @JsonKey(name: 'first_name') String? firstName,
@@ -49,7 +58,8 @@ abstract class LegalEntityModel with _$LegalEntityModel {
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
}) = _LegalEntityModel; }) = _LegalEntityModel;
factory LegalEntityModel.fromJson(Map<String, dynamic> json) => _$LegalEntityModelFromJson(json); factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
_$LegalEntityModelFromJson(json);
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
static String? normalizeApiDate(String? value) { static String? normalizeApiDate(String? value) {
@@ -62,25 +72,21 @@ abstract class LegalEntityModel with _$LegalEntityModel {
return value; return value;
} }
String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); String? get normalizedPassportIssueDate =>
normalizeApiDate(passportIssueDate);
String? get normalizedBirthDate => normalizeApiDate(birthDate); String? get normalizedBirthDate => normalizeApiDate(birthDate);
} }
@freezed @freezed
abstract class CertPhotosModel with _$CertPhotosModel { abstract class CertPhotosModel with _$CertPhotosModel {
const factory CertPhotosModel({ const factory CertPhotosModel({required String url}) = _CertPhotosModel;
required String url,
}) = _CertPhotosModel;
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json); factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
_$CertPhotosModelFromJson(json);
} }
enum ModerationStatus { enum ModerationStatus { approved, created, canceled }
approved,
created,
canceled,
}
@freezed @freezed
abstract class CanceledModerationModel with _$CanceledModerationModel { abstract class CanceledModerationModel with _$CanceledModerationModel {
@@ -90,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
required List<String>? additional, required List<String>? additional,
}) = _CanceledModerationModel; }) = _CanceledModerationModel;
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json); factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
_$CanceledModerationModelFromJson(json);
} }

View File

@@ -13,5 +13,6 @@ abstract class SbpBankModel with _$SbpBankModel {
String? bic, String? bic,
}) = _SbpBankModel; }) = _SbpBankModel;
factory SbpBankModel.fromJson(Map<String, dynamic> json) => _$SbpBankModelFromJson(json); factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
_$SbpBankModelFromJson(json);
} }

View File

@@ -1,30 +1,52 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; 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/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/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/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/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/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/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:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> { class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final ConsultantRepository consultantRepository; final ConsultantRepository consultantRepository;
final Duration scoringPollInterval;
bool _update = false; bool _update = false;
String? _userSlug;
Timer? _scoringPollTimer;
static const String individualPersonLegalType = 'individualPerson'; static const String individualPersonLegalType = 'individualPerson';
RegisterConsultantCubit({required this.consultantRepository}) RegisterConsultantCubit({
: super( required this.consultantRepository,
const RegisterConsultantState( this.scoringPollInterval = const Duration(seconds: 3),
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), }) : super(
loading: true, const RegisterConsultantState(
acceptOffer: false, consultant: ConsultantProfileModel(
acceptFrdoAgreement: false, socialLinks: SocialLinksModel(),
isUpdating: false, 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<List<SbpBankModel>> _loadSbpBanks() async { Future<List<SbpBankModel>> _loadSbpBanks() async {
try { try {
@@ -45,7 +67,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final name = bank.bankName.toLowerCase(); final name = bank.bankName.toLowerCase();
final shortName = (bank.shortName ?? '').toLowerCase(); final shortName = (bank.shortName ?? '').toLowerCase();
final bic = (bank.bic ?? '').toLowerCase(); final bic = (bank.bic ?? '').toLowerCase();
return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized); return name.contains(normalized) ||
shortName.contains(normalized) ||
bic.contains(normalized);
}).toList(); }).toList();
} }
@@ -64,21 +88,29 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
Future<void> getMyRequestStatus(String slug) async { Future<void> getMyRequestStatus(String slug) async {
try { try {
_update = true; _update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); _userSlug = slug;
final myRequestStatus = await consultantRepository.getMyRequestStatus(
slug,
);
final certPhotos = await consultantRepository.getConsultantCertPhotos(); final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks(); final sbpBanks = await _loadSbpBanks();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { List<CertificatesModel>? certificates = myRequestStatus
final course = courseMap.values.first; .educationModel
.completedCourses
?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(title: course.title, date: course.date); return CertificatesModel(title: course.title, date: course.date);
}).toList(); })
.toList();
String? sanitizeLink(String? link) { String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null; return (link != null && link.isNotEmpty) ? link : null;
} }
final legal = myRequestStatus.legalEntity; final legal = myRequestStatus.legalEntity;
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
emit( emit(
RegisterConsultantState( RegisterConsultantState(
@@ -89,9 +121,12 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), 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, legalType: legal?.type ?? individualPersonLegalType,
lastName: _emptyToNull(legal?.lastName), lastName: _emptyToNull(legal?.lastName),
firstName: _emptyToNull(legal?.firstName), firstName: _emptyToNull(legal?.firstName),
@@ -120,13 +155,30 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
certPhotos: certPhotos, certPhotos: certPhotos,
sbpBanks: sbpBanks, sbpBanks: sbpBanks,
isUpdating: _update, isUpdating: _update,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
fieldErrors: const <String, String>{},
error: null, error: null,
), ),
); );
if (scoringStatus.isProcessing) {
startScoringPoll();
}
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(loading: false, error: e.toString())); final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
} }
} }
@@ -137,8 +189,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
return value.trim(); return value.trim();
} }
Future<void> disableLoading() async { Future<void> disableLoading({String? slug}) async {
try { try {
setUserSlug(slug);
final certPhotos = await consultantRepository.getConsultantCertPhotos(); final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks(); final sbpBanks = await _loadSbpBanks();
emit( emit(
@@ -147,6 +200,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
certPhotos: certPhotos, certPhotos: certPhotos,
sbpBanks: sbpBanks, sbpBanks: sbpBanks,
error: null, error: null,
fieldErrors: const <String, String>{},
awaitingScoringOutcome: false,
consultant: state.consultant.copyWith( consultant: state.consultant.copyWith(
legalType: individualPersonLegalType, legalType: individualPersonLegalType,
citizenship: state.consultant.citizenship, citizenship: state.consultant.citizenship,
@@ -158,7 +213,15 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(loading: false, error: e.toString())); final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
} }
} }
@@ -168,11 +231,15 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
if (slug != null) { if (slug != null) {
await getMyRequestStatus(slug); await getMyRequestStatus(slug);
} else { } else {
await disableLoading(); await disableLoading(slug: _userSlug);
} }
} }
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { void updateConsultantProfileModel(
ConsultantProfileModel consultant, [
bool? acceptOffer,
bool? acceptFrdoAgreement,
]) {
emit( emit(
state.copyWith( state.copyWith(
consultant: consultant, consultant: consultant,
@@ -180,12 +247,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
loading: false, loading: false,
error: null, error: null,
fieldErrors: const <String, String>{},
), ),
); );
} }
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); emit(
state.copyWith(
acceptOffer: acceptOffer ?? state.acceptOffer,
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
error: null,
),
);
} }
bool get hasAcceptedRequiredAgreements { bool get hasAcceptedRequiredAgreements {
@@ -202,7 +276,13 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
taxResidentUs: state.consultant.taxResidentUs ?? false, taxResidentUs: state.consultant.taxResidentUs ?? false,
foreignTaxResident: state.consultant.foreignTaxResident ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false,
certificates: state.consultant.certificates certificates: state.consultant.certificates
?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date)) ?.map(
(course) => CertificatesModel(
title: course.title,
date:
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
),
)
.toList(), .toList(),
); );
@@ -235,40 +315,241 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
); );
} }
return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); return withLegalType.copyWith(
agreementAccepted: state.acceptOffer,
frdoAgreementAccepted: state.acceptFrdoAgreement,
);
} }
Future<void> registerConsultant() async { Future<void> 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 <String, String>{},
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
awaitingScoringOutcome: true,
scoringRetryInProgress: false,
),
);
try { try {
if (!state.consultant.hasValidPassportData) {
emit(state.copyWith(error: 'Заполните паспортные данные'));
return;
}
if (state.newAddedPhotos.isNotEmpty) { if (state.newAddedPhotos.isNotEmpty) {
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); await consultantRepository.storeConsultantCertPhotos(
files: state.newAddedPhotos,
);
} }
if (state.deletedPhotoIds.isNotEmpty) { if (state.deletedPhotoIds.isNotEmpty) {
for (final int id in state.deletedPhotoIds) { for (final int id in state.deletedPhotoIds) {
await consultantRepository.deletePhoto(id); await consultantRepository.deletePhoto(id);
} }
} }
await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); await consultantRepository.registerConsultant(
consultant: buildSubmitPayload(),
update: _update,
);
_update = true;
emit(
state.copyWith(
isUpdating: true,
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
scoringRetryInProgress: false,
awaitingScoringOutcome: true,
newAddedPhotos: const <File>[],
deletedPhotoIds: const <int>[],
fieldErrors: const <String, String>{},
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) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); 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<void> retryScoring() async {
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
return;
}
emit(
state.copyWith(
scoringRetryInProgress: true,
error: null,
fieldErrors: const <String, String>{},
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<void> _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<void> addCertPhotos(List<File> files) async { Future<void> addCertPhotos(List<File> files) async {
if (files.isEmpty) { if (files.isEmpty) {
return; return;
} }
try { 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) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
@@ -282,7 +563,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
return; return;
} }
if (path != null) { 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<void> close() {
_stopScoringPoll();
return super.close();
}
} }

View File

@@ -1,5 +1,6 @@
import 'dart:io'; 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_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/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/consultation_request_model.dart';
@@ -17,10 +18,18 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
required bool loading, required bool loading,
required bool isUpdating, required bool isUpdating,
ConsultationRequestModel? requestModel, ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos, @Default(<ConsultantCertPhotoModel>[])
List<ConsultantCertPhotoModel> certPhotos,
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks, @Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
@Default(<File>[]) List<File> newAddedPhotos, @Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds, @Default(<int>[]) List<int> 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(<String, String>{}) Map<String, String> fieldErrors,
String? error, String? error,
}) = _RegisterConsultantState; }) = _RegisterConsultantState;
} }

View File

@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
const AdditionalSettingsScreen({super.key}); const AdditionalSettingsScreen({super.key});
@override @override
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState(); State<AdditionalSettingsScreen> createState() =>
_AdditionalSettingsScreenState();
} }
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> { class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
@override @override
void initState() { void initState() {
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) { if (context
.read<RegisterConsultantCubit>()
.state
.consultant
.hasMedicalEducation !=
null) {
haveMedicalDegree = context haveMedicalDegree = context
.read<RegisterConsultantCubit>() .read<RegisterConsultantCubit>()
.state .state
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
value: publishDiplomasAndCertificatesInProfile, value: publishDiplomasAndCertificatesInProfile,
onTap: () { onTap: () {
setState(() { setState(() {
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile; publishDiplomasAndCertificatesInProfile =
!publishDiplomasAndCertificatesInProfile;
}); });
}, },
), ),
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
setState(() { setState(() {
haveMedicalDegree = !haveMedicalDegree; haveMedicalDegree = !haveMedicalDegree;
}); });
context.read<RegisterConsultantCubit>().updateConsultantProfileModel( context
context.read<RegisterConsultantCubit>().state.consultant.copyWith( .read<RegisterConsultantCubit>()
hasMedicalEducation: haveMedicalDegree, .updateConsultantProfileModel(
), context
); .read<RegisterConsultantCubit>()
.state
.consultant
.copyWith(hasMedicalEducation: haveMedicalDegree),
);
}, },
), ),
], ],

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class BecomeConsultantScreen extends StatefulWidget { class BecomeConsultantScreen extends StatefulWidget {
const BecomeConsultantScreen({super.key}); final String? slug;
const BecomeConsultantScreen({super.key, this.slug});
@override @override
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState(); State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
title: const Text('Стать консультантом'), title: const Text('Стать консультантом'),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
centerTitle: 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( body: const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
InkWell _startRegistrationButton() { InkWell _startRegistrationButton() {
return InkWell( 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), borderRadius: BorderRadius.circular(20),
child: Ink( child: Ink(
height: 52, height: 52,
width: MediaQuery.of(context).size.width - 32, 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: const Center(
child: Text( 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,
),
), ),
), ),
), ),

View File

@@ -13,10 +13,12 @@ class CertificatesAndDiplomasScreen extends StatefulWidget {
const CertificatesAndDiplomasScreen({super.key}); const CertificatesAndDiplomasScreen({super.key});
@override @override
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState(); State<CertificatesAndDiplomasScreen> createState() =>
_CertificatesAndDiplomasScreenState();
} }
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> { class _CertificatesAndDiplomasScreenState
extends State<CertificatesAndDiplomasScreen> {
final scrollController = ScrollController(); final scrollController = ScrollController();
List<DiplomaEntity> diplomasList = [ List<DiplomaEntity> diplomasList = [
@@ -30,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) { if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
null) {
diplomasList = [ diplomasList = [
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map( ...context
.read<RegisterConsultantCubit>()
.state
.consultant
.certificates!
.map(
(e) => DiplomaEntity( (e) => DiplomaEntity(
date: DateTime.tryParse(e.date), date: DateTime.tryParse(e.date),
textEditingController: TextEditingController(text: e.title), textEditingController: TextEditingController(text: e.title),
@@ -71,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
ListView.separated( ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]), itemBuilder: (context, index) =>
_diplomaInfo(diploma: diplomasList[index]),
separatorBuilder: (context, index) => Padding( separatorBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: _divider(context), child: _divider(context),
@@ -94,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
); );
} }
Column _diplomaInfo({ Column _diplomaInfo({required DiplomaEntity diploma}) {
required DiplomaEntity diploma,
}) {
return Column( return Column(
children: [ children: [
CustomTextFormField( CustomTextFormField(
@@ -112,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32), width:
MediaQuery.of(context).size.width -
(diploma.enableDeleteButton ? 92 : 32),
child: ListTile( child: ListTile(
onTap: onTapCalendar(context, diploma), onTap: onTapCalendar(context, diploma),
trailing: Icon( trailing: Icon(
@@ -123,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
title: Text( title: Text(
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи', diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
style: TextStyle( style: TextStyle(
color: diploma.date == null ? Colors.grey.shade400 : Colors.black, color: diploma.date == null
? Colors.grey.shade400
: Colors.black,
), ),
), ),
tileColor: const Color(0xFFF6F6F6), tileColor: const Color(0xFFF6F6F6),
@@ -149,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: const Icon( child: const Icon(Icons.delete_outline_rounded),
Icons.delete_outline_rounded,
),
), ),
), ),
], ],
@@ -191,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
bottom: MediaQuery.of(context).viewInsets.bottom, bottom: MediaQuery.of(context).viewInsets.bottom,
), ),
color: CupertinoColors.systemBackground.resolveFrom(context), color: CupertinoColors.systemBackground.resolveFrom(context),
child: SafeArea( child: SafeArea(top: false, child: child),
top: false,
child: child,
),
), ),
); );
} }
void checkLast() { void checkLast() {
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) { if (diplomasList.last.textEditingController.text.isNotEmpty ||
diplomasList.last.date != null) {
setState(() { setState(() {
diplomasList.add( diplomasList.add(
DiplomaEntity( DiplomaEntity(
@@ -231,17 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
void _saveState() { void _saveState() {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel( context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith( context.read<RegisterConsultantCubit>().state.consultant.copyWith(
certificates: diplomasList certificates: diplomasList
.map( .map(
(e) => CertificatesModel( (e) => CertificatesModel(
title: e.textEditingController.text, title: e.textEditingController.text,
date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), date: e.date == null
), ? ''
) : DateFormat('yyyy-MM-dd').format(e.date!),
.toList()
.sublist(0, diplomasList.length - 1),
), ),
); )
.toList()
.sublist(0, diplomasList.length - 1),
),
);
} }
} }

View File

@@ -1,8 +1,10 @@
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/locator_service.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/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/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -24,7 +26,8 @@ class _LastStepScreenState extends State<LastStepScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); _frdoTapRecognizer = TapGestureRecognizer()
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
} }
@override @override
@@ -46,8 +49,22 @@ class _LastStepScreenState extends State<LastStepScreen> {
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), 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) ...[ if (state.requestModel?.canceledModeration != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Padding( Padding(
@@ -64,12 +81,24 @@ class _LastStepScreenState extends State<LastStepScreen> {
children: [ children: [
Text( Text(
state.requestModel!.canceledModeration!.title, state.requestModel!.canceledModeration!.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2), style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
), ),
if (state.requestModel!.canceledModeration!.additional != null) ...[ if (state.requestModel!.canceledModeration!.additional !=
null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
for (final item in state.requestModel!.canceledModeration!.additional!) ...[ for (final item
Text('• $item', style: const TextStyle(fontSize: 14, height: 1.3)), in state
.requestModel!
.canceledModeration!
.additional!) ...[
Text(
'• $item',
style: const TextStyle(fontSize: 14, height: 1.3),
),
], ],
], ],
], ],
@@ -98,9 +127,16 @@ class _LastStepScreenState extends State<LastStepScreen> {
value: state.acceptFrdoAgreement, value: state.acceptFrdoAgreement,
title: Text.rich( title: Text.rich(
TextSpan( TextSpan(
style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), style: const TextStyle(
fontSize: 18,
height: 1.2,
color: Color(0xFF323F4B),
),
children: [ children: [
const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), const TextSpan(
text:
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
),
TextSpan( TextSpan(
text: 'ФРДО', text: 'ФРДО',
style: TextStyle( style: TextStyle(
@@ -120,16 +156,31 @@ class _LastStepScreenState extends State<LastStepScreen> {
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
), ),
], ],
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton( child: TextButton(
onPressed: () async => await launchUrl( onPressed: () async => await launchUrl(
Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf'), Uri.parse(
hidePlans
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
),
), ),
child: Text( child: Text(
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', hidePlans
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ? 'Политика конфиденциальности и пользовательское соглашение'
: 'Договор оферты',
style: const TextStyle(
height: 1,
fontSize: 16,
color: Color(0xFF323F4B),
decoration: TextDecoration.underline,
),
), ),
), ),
), ),
@@ -142,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
return Container( return Container(
height: 0.4, height: 0.4,
width: MediaQuery.of(context).size.width - 32, 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),
),
); );
} }
} }

View File

@@ -1,8 +1,11 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.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/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_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/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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
@@ -100,9 +103,7 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
birthPlaceController = TextEditingController( birthPlaceController = TextEditingController(
text: consultant.birthPlace ?? '', text: consultant.birthPlace ?? '',
); );
citizenshipController = TextEditingController( citizenshipController = TextEditingController(text: consultant.citizenship);
text: consultant.citizenship,
);
registrationAddressController = TextEditingController( registrationAddressController = TextEditingController(
text: consultant.registrationAddress ?? '', text: consultant.registrationAddress ?? '',
); );
@@ -269,258 +270,321 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
} }
} }
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>( return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
listener: (context, state) => _hydrateSbpBankNameIfNeeded(), listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
child: SingleChildScrollView( child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
padding: const EdgeInsets.only( buildWhen: (previous, current) =>
bottom: 80, previous.fieldErrors != current.fieldErrors,
right: 16, builder: (context, state) {
left: 16, final errors = state.fieldErrors;
top: 16, return SingleChildScrollView(
), padding: const EdgeInsets.only(
child: Column( bottom: 80,
mainAxisSize: MainAxisSize.min, right: 16,
crossAxisAlignment: CrossAxisAlignment.start, left: 16,
children: [ top: 16,
const Text(
'Паспортные данные',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
), ),
_sectionTitle('Персональные данные (как в паспорте)'), child: Column(
CustomTextFormField( mainAxisSize: MainAxisSize.min,
hintText: 'Фамилия', crossAxisAlignment: CrossAxisAlignment.start,
controller: lastNameController,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Имя',
controller: firstNameController,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Отчество',
controller: middleNameController,
onChanged: (_) => _syncToCubit(),
),
_sectionTitle('Идентификация'),
CustomTextFormField(
hintText: 'ИНН (12 цифр)',
controller: innController,
keyboardType: TextInputType.number,
maxLength: 12,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'СНИЛС',
controller: snilsController,
keyboardType: TextInputType.number,
inputFormatters: [snilsMask],
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
controller: birthDateController,
keyboardType: TextInputType.number,
inputFormatters: [dateMaskBirth],
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Место рождения',
controller: birthPlaceController,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Гражданство (ISO, например RU)',
controller: citizenshipController,
maxLength: 2,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Адрес регистрации',
controller: registrationAddressController,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Адрес проживания',
controller: residenceAddressController,
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 8),
CheckboxListTile(
value: taxResidentUs,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: appTheme.blueA400,
title: const Text(
'Я являюсь налоговым резидентом США',
style: TextStyle(fontSize: 16, height: 1.2),
),
onChanged: (value) {
setState(() => taxResidentUs = value ?? false);
_syncToCubit();
},
),
CheckboxListTile(
value: foreignTaxResident,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: appTheme.blueA400,
title: const Text(
'Я являюсь иностранным налоговым резидентом',
style: TextStyle(fontSize: 16, height: 1.2),
),
onChanged: (value) {
setState(() => foreignTaxResident = value ?? false);
_syncToCubit();
},
),
_sectionTitle('Паспорт'),
Row(
children: [ children: [
Expanded( const PassportScreenTitle(title: 'Паспортные данные'),
const PassportSectionTitle(
title: 'Персональные данные (как в паспорте)',
),
PassportFieldWithError(
error: errors['last_name'],
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Серия', hintText: 'Фамилия',
controller: seriesController, controller: lastNameController,
keyboardType: TextInputType.number, hasError: errors.containsKey('last_name'),
maxLength: 4,
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),
), ),
const SizedBox(width: 8), const SizedBox(height: 12),
Expanded( PassportFieldWithError(
error: errors['first_name'],
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Номер', hintText: 'Имя',
controller: numberController, controller: firstNameController,
keyboardType: TextInputType.number, hasError: errors.containsKey('first_name'),
maxLength: 6,
onChanged: (_) => _syncToCubit(), 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<SbpBankModel>(
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<RegisterConsultantCubit>()
.searchSbpBanks(query);
},
itemBuilder: (context, bank) {
return ListTile(
dense: true,
title: Text(
bank.bankName,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black900,
),
),
subtitle:
bank.shortName == null || bank.shortName!.isEmpty
? null
: Text(
bank.shortName!,
style: TextStyles.textSMedium.copyWith(
color: appTheme.grey7B,
),
),
);
},
decorationBuilder: (context, child) => Material(
type: MaterialType.card,
elevation: 4,
borderRadius: BorderRadius.circular(8),
color: appTheme.whiteA700,
child: child,
),
emptyBuilder: (context) => Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Ничего не найдено',
style: TextStyles.textSMedium.copyWith(
color: appTheme.grey7B,
),
),
),
onSelected: _selectSbpBank,
),
),
const SizedBox(height: 16),
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
const SizedBox(height: 16),
Text(
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
style: TextStyles.textSMedium.copyWith(
color: appTheme.blueA400,
),
),
], ],
), ),
const SizedBox(height: 12), );
CustomTextFormField( },
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
controller: issueDateController,
keyboardType: TextInputType.number,
inputFormatters: [dateMaskIssue],
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Код подразделения',
controller: issuerCodeController,
keyboardType: TextInputType.number,
inputFormatters: [issuerCodeMask],
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
CustomTextFormField(
hintText: 'Кем выдан',
controller: issuerController,
onChanged: (_) => _syncToCubit(),
),
_sectionTitle('Реквизиты СБП'),
CustomTextFormField(
hintText: 'Телефон для выплат через СБП',
controller: sbpPhoneController,
keyboardType: TextInputType.phone,
inputFormatters: [phoneMask],
onChanged: (_) => _syncToCubit(),
),
const SizedBox(height: 12),
TypeAheadField<SbpBankModel>(
debounceDuration: const Duration(milliseconds: 250),
controller: sbpBankController,
suggestionsController: sbpBankSuggestionsController,
builder: (context, controller, focusNode) => CustomTextFormField(
controller: controller,
focusNode: focusNode,
hintText: 'Банк получатель (поиск)',
onChanged: (value) {
_clearSbpBankIfNeeded(value);
},
),
suggestionsCallback: (query) async {
return context.read<RegisterConsultantCubit>().searchSbpBanks(
query,
);
},
itemBuilder: (context, bank) {
return ListTile(
dense: true,
title: Text(
bank.bankName,
style: const TextStyle(fontSize: 16, color: Colors.black),
),
subtitle: bank.shortName == null || bank.shortName!.isEmpty
? null
: Text(
bank.shortName!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
);
},
decorationBuilder: (context, child) => Material(
type: MaterialType.card,
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: child,
),
emptyBuilder: (context) => const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Ничего не найдено',
style: TextStyle(color: Colors.black54),
),
),
onSelected: _selectSbpBank,
),
const SizedBox(height: 16),
const Divider(height: 0.4, thickness: 0.4, color: Colors.black26),
const SizedBox(height: 16),
Text(
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
style: TextStyle(
fontSize: 14,
height: 1.2,
color: appTheme.blueA400,
),
),
],
),
), ),
); );
} }

View File

@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>( BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) { 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 addedPhotos = state.newAddedPhotos;
final totalPhotosCount = photos.length + addedPhotos.length; final totalPhotosCount = photos.length + addedPhotos.length;
return Column( return Column(
children: [ children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], if (isUploading) ...[
const LinearProgressIndicator(),
const SizedBox(height: 16),
],
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
crossAxisCount: 3, const SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 3 / 4, crossAxisCount: 3,
crossAxisSpacing: 8, childAspectRatio: 3 / 4,
mainAxisSpacing: 8, crossAxisSpacing: 8,
), mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
if (index == totalPhotosCount) { if (index == totalPhotosCount) {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(), onTap: () => _pickAndUploadPhotos(),
child: Ink( 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), child: Icon(Icons.add, color: appTheme.blueA400),
), ),
); );
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
return _LocalCertPhotoCard( return _LocalCertPhotoCard(
photo: localPhoto, photo: localPhoto,
allPhotos: addedPhotos, allPhotos: addedPhotos,
onRemove: () => _removePhoto(path: localPhoto.uri.path), onRemove: () =>
_removePhoto(path: localPhoto.uri.path),
); );
} }
} }
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
}); });
try { try {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList()); await context.read<RegisterConsultantCubit>().addCertPhotos(
newImages.map((file) => File(file.path)).toList(),
);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
final List<ConsultantCertPhotoModel> allPhotos; final List<ConsultantCertPhotoModel> allPhotos;
final VoidCallback onRemove; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
onTap: () { onTap: () {
context.pushTransparentRoute( context.pushTransparentRoute(
rootNavigator: true, 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), borderRadius: BorderRadius.circular(20),
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
Ink( Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)), 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( Positioned(
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), decoration: const BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), 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<File> allPhotos; final List<File> allPhotos;
final VoidCallback onRemove; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), decoration: const BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
), ),
), ),
), ),

View File

@@ -13,7 +13,8 @@ class RegistrationAsScreen extends StatefulWidget {
enum RegistrationAsType { physical, selfBusy, iP, ooo } enum RegistrationAsType { physical, selfBusy, iP, ooo }
class _RegistrationAsScreenState extends State<RegistrationAsScreen> { class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
static const RegistrationAsType registrationAsType = RegistrationAsType.physical; static const RegistrationAsType registrationAsType =
RegistrationAsType.physical;
@override @override
void initState() { void initState() {
@@ -24,7 +25,9 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
} }
final cubit = context.read<RegisterConsultantCubit>(); final cubit = context.read<RegisterConsultantCubit>();
cubit.updateConsultantProfileModel( cubit.updateConsultantProfileModel(
cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), cubit.state.consultant.copyWith(
legalType: RegisterConsultantCubit.individualPersonLegalType,
),
cubit.state.acceptOffer, cubit.state.acceptOffer,
cubit.state.acceptFrdoAgreement, cubit.state.acceptFrdoAgreement,
); );
@@ -41,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), 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), const SizedBox(height: 16),
Column( Column(
@@ -59,7 +69,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), child: Text(
'Физического лица',
style: TextStyle(fontSize: 18, height: 1),
),
), ),
), ),
], ],
@@ -77,7 +90,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), child: Text(
'Самозанятого',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
), ),
), ),
], ],
@@ -95,7 +115,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), child: Text(
'Индивидуального предпринимателя',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
), ),
), ),
], ],
@@ -113,17 +140,29 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), child: Text(
'Общества с ограниченной ответственностью',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
), ),
), ),
], ],
), ),
], ],
), ),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), child: Text(
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
),
), ),
], ],
), ),
@@ -134,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
return Container( return Container(
height: 0.4, height: 0.4,
width: MediaQuery.of(context).size.width - 32, 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),
),
); );
} }
} }

View File

@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
var maskFormatter = MaskTextInputFormatter( var maskFormatter = MaskTextInputFormatter(
mask: 'https://########################################', mask: 'https://########################################',
filter: { filter: {'#': RegExp(r"[a-zA-Z. ]+")},
'#': RegExp(r"[a-zA-Z. ]+"),
},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
var maskTgFormatter = MaskTextInputFormatter( var maskTgFormatter = MaskTextInputFormatter(
mask: 'https://tg.me/########################################', mask: 'https://tg.me/########################################',
filter: { filter: {'#': RegExp(r"[a-zA-Z. ]+")},
'#': RegExp(r"[a-zA-Z. ]+"),
},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
var maskVkFormatter = MaskTextInputFormatter( var maskVkFormatter = MaskTextInputFormatter(
mask: 'https://vk.com/########################################', mask: 'https://vk.com/########################################',
filter: { filter: {'#': RegExp(r"[a-zA-Z. ]+")},
'#': RegExp(r"[a-zA-Z. ]+"),
},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
@override @override
void initState() { void initState() {
super.initState(); super.initState();
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? ''; vkController.text =
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? ''; context
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? ''; .read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.vkLink ??
'';
telegramController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.tgLink ??
'';
webSiteController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.personalSiteLink ??
'';
} }
@override @override
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset( child: SvgPicture.asset('assets/icons/vk.svg'),
'assets/icons/vk.svg',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset( child: SvgPicture.asset('assets/icons/telegram.svg'),
'assets/icons/telegram.svg',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset( child: SvgPicture.asset('assets/icons/web.svg'),
'assets/icons/web.svg',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
String? personalSiteLink, String? personalSiteLink,
}) { }) {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel( context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith( context.read<RegisterConsultantCubit>().state.consultant.copyWith(
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith( socialLinks: context
tgLink: tgLink == null .read<RegisterConsultantCubit>()
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink .state
: tgLink.isEmpty .consultant
? null .socialLinks
: tgLink, ?.copyWith(
vkLink: vkLink == null tgLink: tgLink == null
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink ? context
: vkLink.isEmpty .read<RegisterConsultantCubit>()
? null .state
: vkLink, .consultant
personalSiteLink: personalSiteLink == null .socialLinks
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink ?.tgLink
: personalSiteLink.isEmpty : tgLink.isEmpty
? null ? null
: personalSiteLink, : tgLink,
), vkLink: vkLink == null
), ? context
); .read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.vkLink
: vkLink.isEmpty
? null
: vkLink,
personalSiteLink: personalSiteLink == null
? context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.personalSiteLink
: personalSiteLink.isEmpty
? null
: personalSiteLink,
),
),
);
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/core/utils/image_constant.dart'; 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/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.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_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/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/additional_settings_screen.dart';
@@ -22,26 +23,37 @@ import 'package:lottie/lottie.dart';
class StepperBecomeConsultantScreen extends StatefulWidget { class StepperBecomeConsultantScreen extends StatefulWidget {
final String? slug; final String? slug;
final bool? isCanceled; 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 @override
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState(); State<StepperBecomeConsultantScreen> createState() =>
_StepperBecomeConsultantScreenState();
} }
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> { class _StepperBecomeConsultantScreenState
extends State<StepperBecomeConsultantScreen> {
double progressValue = 0; double progressValue = 0;
int step = 1; int step = 1;
final pageController = PageController(); final pageController = PageController();
late final RegisterConsultantCubit registrationConsultantCubit; late final RegisterConsultantCubit registrationConsultantCubit;
bool isLoading = false; bool isLoading = false;
bool _successSheetShown = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.slug == null) { if (widget.isNewRegistration || widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading(); registrationConsultantCubit = sl<RegisterConsultantCubit>()
..disableLoading(slug: widget.slug);
} else { } else {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!); registrationConsultantCubit = sl<RegisterConsultantCubit>()
..getMyRequestStatus(widget.slug!);
} }
} }
@@ -59,16 +71,97 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
]; ];
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<RegisterConsultantCubit>( return BlocProvider<RegisterConsultantCubit>(
create: (context) => registrationConsultantCubit, create: (context) => registrationConsultantCubit,
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>( child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
listenWhen: (previous, current) =>
previous.awaitingScoringOutcome &&
!current.awaitingScoringOutcome &&
current.alfaScoringStatus.isComplete,
listener: (context, state) {
_showSuccessSheet(context);
},
builder: (context, state) { builder: (context, state) {
if (state.loading) { if (state.loading) {
return const Scaffold(body: Loader()); return const Scaffold(body: Loader());
} }
if (state.error != null && state.requestModel == null && widget.slug != null) { if (state.error != null &&
state.requestModel == null &&
widget.slug != null &&
state.alfaScoringStatus == AlfaScoringStatus.none) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
centerTitle: true, centerTitle: true,
@@ -87,11 +180,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
softWrap: true, softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
), ),
), ),
body: DataErrorWidget( body: DataErrorWidget(
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug), onReload: () => context
.read<RegisterConsultantCubit>()
.reloadData(widget.slug),
), ),
); );
} }
@@ -100,8 +197,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
progressValue = 1 / totalSteps; progressValue = 1 / totalSteps;
} }
final submitDisabled =
isLoading ||
state.isScoringPolling ||
state.scoringRetryInProgress;
return Scaffold( return Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButtonLocation:
FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row( floatingActionButton: Row(
children: [ children: [
Container( Container(
@@ -113,20 +216,29 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0, highlightElevation: 0,
focusElevation: 0, focusElevation: 0,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: const Color(0xFFEAF2FD), backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async { onPressed: () async {
setState(() { setState(() {
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}); });
}, },
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400), label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
), ),
), ),
if (widget.isCanceled == true && step != totalSteps) if (widget.isCanceled == true && step != totalSteps)
InkWell( InkWell(
onTap: () { onTap: () {
final canceledModeration = state.requestModel?.canceledModeration; final canceledModeration =
state.requestModel?.canceledModeration;
if (canceledModeration == null) return; if (canceledModeration == null) return;
showDialog( showDialog(
context: context, context: context,
@@ -137,12 +249,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (canceledModeration.additional != null) if (canceledModeration.additional != null)
for (final item in canceledModeration.additional!) ...[ for (final item
in canceledModeration.additional!) ...[
Text('• $item'), Text('• $item'),
], ],
], ],
), ),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
), ),
); );
}, },
@@ -150,11 +268,19 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: Ink( child: Ink(
height: 48, height: 48,
width: MediaQuery.of(context).size.width * 0.6, width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
child: const Center( child: const Center(
child: Text( child: Text(
'Комментарий', 'Комментарий',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
), ),
), ),
), ),
@@ -170,13 +296,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0, highlightElevation: 0,
elevation: 0, elevation: 0,
focusElevation: 0, focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
backgroundColor: isLoading borderRadius: BorderRadius.circular(20),
),
backgroundColor: submitDisabled
? Colors.grey ? Colors.grey
: widget.isCanceled == true && step != totalSteps : widget.isCanceled == true && step != totalSteps
? const Color(0xFFEAF2FD) ? const Color(0xFFEAF2FD)
: appTheme.blueA400, : appTheme.blueA400,
onPressed: isLoading onPressed: submitDisabled
? null ? null
: () async { : () async {
if (step == totalSteps) { if (step == totalSteps) {
@@ -184,84 +312,73 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding: const EdgeInsets.symmetric(
content: const Text('Заполните паспортные данные'), horizontal: 4,
),
content: const Text(
'Заполните паспортные данные',
),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Ок'), child: const Text('Ок'),
onPressed: () => Navigator.of(context).pop(), onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
); );
return; return;
} }
if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { if (registrationConsultantCubit
.hasAcceptedRequiredAgreements) {
setState(() { setState(() {
isLoading = true; isLoading = true;
}); });
try { try {
await registrationConsultantCubit.registerConsultant(); await registrationConsultantCubit
.registerConsultant();
if (!context.mounted) return; if (!context.mounted) return;
if (registrationConsultantCubit.state.error != null) { if (registrationConsultantCubit.state.error !=
return; null) {
} showDialog(
await showModalBottomSheet( context: context,
useRootNavigator: true, builder: (context) => AlertDialog(
showDragHandle: true, actionsPadding:
context: context, const EdgeInsets.symmetric(
builder: (context) => Container( horizontal: 4,
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),
),
),
), ),
content: Text(
registrationConsultantCubit
.state
.error!,
),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
), );
); return;
}
// Success sheet is shown by BlocListener when scoring completes.
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(e.toString()), content: Text(e.toString()),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Ок'), child: const Text('Ок'),
onPressed: () => Navigator.of(context).pop(), onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
@@ -275,7 +392,9 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding: const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text( content: Text(
state.isUpdating state.isUpdating
? 'Примите договор публичной оферты' ? 'Примите договор публичной оферты'
@@ -295,17 +414,25 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
} else { } else {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
setState(() { setState(() {
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}); });
} }
}, },
label: isLoading label: submitDisabled && step == totalSteps
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: widget.isCanceled == true && step != totalSteps : widget.isCanceled == true && step != totalSteps
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) ? Icon(
Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text( : Text(
step == totalSteps ? 'Отправить' : 'Продолжить', step == totalSteps ? 'Отправить' : 'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(color: Colors.white), style: theme.textTheme.titleSmall!.copyWith(
color: Colors.white,
),
), ),
), ),
), ),
@@ -314,11 +441,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
leading: Builder( leading: Builder(
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), builder: (context) => IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => GoRouter.of(context).pop(),
),
), ),
centerTitle: true, centerTitle: true,
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), title: SvgPicture.asset(
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], ImageConstant.imgLogoEatvio,
height: 48,
fit: BoxFit.contain,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('шаг $step из $totalSteps'),
),
],
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, 32), preferredSize: Size(MediaQuery.of(context).size.width, 32),
child: Padding( child: Padding(
@@ -326,13 +465,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 350), duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut, curve: Curves.easeInOut,
tween: Tween<double>(begin: 1 / totalSteps, end: progressValue), tween: Tween<double>(
begin: 1 / totalSteps,
end: progressValue,
),
builder: (context, value, _) => LinearProgressIndicator( builder: (context, value, _) => LinearProgressIndicator(
value: value, value: value,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
minHeight: 8, minHeight: 8,
backgroundColor: Colors.grey.shade100, backgroundColor: Colors.grey.shade100,
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400), valueColor: AlwaysStoppedAnimation<Color>(
appTheme.blueA400,
),
), ),
), ),
), ),

View File

@@ -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),
],
],
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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<RegisterConsultantCubit, RegisterConsultantState>(
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<RegisterConsultantCubit>()
.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,
};
}
}

View File

@@ -12,16 +12,25 @@ class ConsultantRepository {
const ConsultantRepository({required this.consultantService}); const ConsultantRepository({required this.consultantService});
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async { Future<void> registerConsultant({
required ConsultantProfileModel consultant,
bool update = false,
}) async {
if (update) { if (update) {
return await consultantService.consultantUpdate(consultantModel: consultant); return await consultantService.consultantUpdate(
consultantModel: consultant,
);
} else { } else {
return await consultantService.consultantRegistration(consultantModel: consultant); return await consultantService.consultantRegistration(
consultantModel: consultant,
);
} }
} }
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта. /// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async { Future<ConsultationRequestModel> getConsultantRequestStatus(
String slug,
) async {
return await consultantService.getConsultantRequestStatus(slug: slug); return await consultantService.getConsultantRequestStatus(slug: slug);
} }
@@ -38,12 +47,18 @@ class ConsultantRepository {
return await consultantService.getConsultantCertPhotos(); return await consultantService.getConsultantCertPhotos();
} }
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async { Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
required List<File> files,
}) async {
final multipartFiles = <MultipartFile>[]; final multipartFiles = <MultipartFile>[];
for (final file in files) { for (final file in files) {
final fileBytes = await file.readAsBytes(); 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); multipartFiles.add(multipartFile);
} }
@@ -51,15 +66,21 @@ class ConsultantRepository {
return []; return [];
} }
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); return await consultantService.storeConsultantCertPhotos(
photos: multipartFiles,
);
} }
Future<void> hideConsultantCertPhoto(int id) async { Future<void> hideConsultantCertPhoto(int id) async {
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); await consultantService.hideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
} }
Future<void> unHideConsultantCertPhoto(int id) async { Future<void> unHideConsultantCertPhoto(int id) async {
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); await consultantService.unHideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
} }
Future<void> deletePhoto(int id) async { Future<void> deletePhoto(int id) async {

View File

@@ -12,24 +12,35 @@ abstract class ConsultantService {
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
@POST('/api/v2/consultant_request/registration') @POST('/api/v2/consultant_request/registration')
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); Future<void> consultantRegistration({
@Body() required ConsultantProfileModel consultantModel,
});
@POST('/api/consultant_request/photos/upload') @POST('/api/consultant_request/photos/upload')
@MultiPart() @MultiPart()
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos}); Future<List<String>> uploadPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data')
List<MultipartFile>? photos,
});
/// Публичный статус консультанта для просмотра профиля. /// Публичный статус консультанта для просмотра профиля.
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`. /// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
@GET('/api/profile/consultant/{slug}/status') @GET('/api/profile/consultant/{slug}/status')
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug}); Future<ConsultationRequestModel> getConsultantRequestStatus({
@Path() required String slug,
});
/// Свой статус заявки для редактирования/модерации. /// Свой статус заявки для редактирования/модерации.
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
@GET('/api/v2/consultant_request/{slug}/status') @GET('/api/v2/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug}); Future<ConsultationRequestModel> getMyRequestStatus({
@Path() required String slug,
});
@PUT('/api/v2/consultant_request/update') @PUT('/api/v2/consultant_request/update')
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); Future<void> consultantUpdate({
@Body() required ConsultantProfileModel consultantModel,
});
@GET('/api/finances/sbp_banks') @GET('/api/finances/sbp_banks')
Future<List<SbpBankModel>> getSbpBanks(); Future<List<SbpBankModel>> getSbpBanks();
@@ -40,14 +51,19 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/store') @POST('/api/v2/consultant_request/cert_photos/store')
@MultiPart() @MultiPart()
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({ Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos, @Part(name: 'photos[]', contentType: 'multipart/form-data')
required List<MultipartFile> photos,
}); });
@POST('/api/v2/consultant_request/cert_photos/hide') @POST('/api/v2/consultant_request/cert_photos/hide')
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload}); Future<void> hideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/un_hide') @POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload}); Future<void> unHideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id}); Future<void> deletePhoto({@Path() required int id});

View File

@@ -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 <String, String>{},
});
final String summary;
final Map<String, String> 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 = <String, String>{};
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 'Проверьте поле';
}

View File

@@ -238,7 +238,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8),
child: InkWell( child: InkWell(
onTap: () => GoRouter.of(context).pushNamed('becomeConsultantScreen'), onTap: () => GoRouter.of(context).pushNamed(
'becomeConsultantScreen',
extra: {'slug': state.profile.slug},
),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Ink( child: Ink(
height: 52, height: 52,

View File

@@ -33,4 +33,3 @@ void main() {
}); });
}); });
} }

View File

@@ -100,4 +100,3 @@ void main() {
}); });
}); });
} }

View File

@@ -63,7 +63,10 @@ void main() {
}); });
test('toJson serializes all fields correctly', () { 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(); final json = model.toJson();
@@ -105,7 +108,10 @@ void main() {
test('toJson serializes all fields correctly', () { test('toJson serializes all fields correctly', () {
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); const certificates = CertificatesModel(
title: 'Course 1',
date: '2024-01-01',
);
const model = ConsultantProfileModel( const model = ConsultantProfileModel(
hasMedicalEducation: true, hasMedicalEducation: true,
socialLinks: socialLinks, socialLinks: socialLinks,

View File

@@ -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/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -46,7 +47,11 @@ void main() {
}); });
test('fromJson handles null additional field', () { 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); final model = CanceledModerationModel.fromJson(json);
@@ -56,7 +61,11 @@ void main() {
}); });
test('toJson serializes all fields correctly', () { 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(); final json = model.toJson();
@@ -133,7 +142,7 @@ void main() {
'inn': '123456789012', 'inn': '123456789012',
'snils': '123-456-789 00', 'snils': '123-456-789 00',
'state': 'created', 'state': 'created',
'alfa_scoring_status': 'pending', 'alfa_scoring_status': 'scoring_failed',
'alfa_scoring_request_id': 'abc', 'alfa_scoring_request_id': 'abc',
'passport_series': '1234', 'passport_series': '1234',
'passport_number': '567890', 'passport_number': '567890',
@@ -154,19 +163,35 @@ void main() {
expect(model.legalEntity!.inn, '123456789012'); expect(model.legalEntity!.inn, '123456789012');
expect(model.legalEntity!.passportSeries, '1234'); expect(model.legalEntity!.passportSeries, '1234');
expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15');
expect(model.legalEntity!.alfaScoringStatus, 'pending'); expect(
model.legalEntity!.alfaScoringStatus,
AlfaScoringStatus.scoringFailed,
);
}); });
test('normalizeApiDate strips time from DateTime.toString format', () { test('normalizeApiDate strips time from DateTime.toString format', () {
expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22'); expect(
LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'),
'2023-09-22',
);
expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22');
expect(LegalEntityModel.normalizeApiDate(null), isNull); expect(LegalEntityModel.normalizeApiDate(null), isNull);
}); });
test('toJson serializes ConsultationRequestModel correctly', () { test('toJson serializes ConsultationRequestModel correctly', () {
const educationModel = EducationModel(requestState: ModerationStatus.approved); const educationModel = EducationModel(
const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; requestState: ModerationStatus.approved,
const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null); );
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(); final json = model.toJson();

View File

@@ -12,7 +12,7 @@ void main() {
acceptOffer: false, acceptOffer: false,
loading: false, loading: false,
requestModel: null, requestModel: null,
isUpdating: false isUpdating: false,
); );
expect(state.consultant, consultant); expect(state.consultant, consultant);
@@ -20,6 +20,8 @@ void main() {
expect(state.loading, false); expect(state.loading, false);
expect(state.requestModel, isNull); expect(state.requestModel, isNull);
expect(state.certPhotos, isEmpty); expect(state.certPhotos, isEmpty);
expect(state.awaitingScoringOutcome, isFalse);
expect(state.fieldErrors, isEmpty);
}); });
test('creates instance with custom values', () { test('creates instance with custom values', () {
@@ -38,7 +40,7 @@ void main() {
loading: true, loading: true,
requestModel: null, requestModel: null,
certPhotos: [certPhoto], certPhotos: [certPhoto],
isUpdating: false isUpdating: false,
); );
expect(state.acceptOffer, true); expect(state.acceptOffer, true);
@@ -54,7 +56,7 @@ void main() {
acceptOffer: false, acceptOffer: false,
loading: false, loading: false,
requestModel: null, requestModel: null,
isUpdating: false isUpdating: false,
); );
final updatedState = originalState.copyWith( final updatedState = originalState.copyWith(
@@ -92,7 +94,7 @@ void main() {
loading: false, loading: false,
requestModel: null, requestModel: null,
certPhotos: [certPhoto1], certPhotos: [certPhoto1],
isUpdating: false isUpdating: false,
); );
final updatedState = originalState.copyWith( final updatedState = originalState.copyWith(
@@ -100,9 +102,14 @@ void main() {
); );
expect(updatedState.certPhotos.length, 2); expect(updatedState.certPhotos.length, 2);
expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg'); expect(
expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg'); updatedState.certPhotos.first.url,
'https://example.com/photo1.jpg',
);
expect(
updatedState.certPhotos.last.url,
'https://example.com/photo2.jpg',
);
}); });
}); });
} }

View File

@@ -1,3 +1,5 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_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/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/consultation_request_model.dart';
@@ -27,18 +29,47 @@ void main() {
late List<SbpBankModel> sbpBanksFromFixture; late List<SbpBankModel> sbpBanksFromFixture;
late ConsultantProfileModel consultantProfile; late ConsultantProfileModel consultantProfile;
ConsultationRequestModel requestWithScoring(String scoringStatus) {
final json = FixtureReader.readAsMap(
'consultant/consultation_request.json',
);
final legal = Map<String, dynamic>.from(
json['legal_entity_resource'] as Map,
);
legal['alfa_scoring_status'] = scoringStatus;
json['legal_entity_resource'] = legal;
return ConsultationRequestModel.fromJson(json);
}
setUp(() { setUp(() {
repository = MockConsultantRepository(); repository = MockConsultantRepository();
cubit = RegisterConsultantCubit(consultantRepository: repository); cubit = RegisterConsultantCubit(
requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); consultantRepository: repository,
certPhotosFromFixture = FixtureReader.readAsList( scoringPollInterval: const Duration(milliseconds: 30),
'consultant/consultant_cert_photos.json', );
).map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>)).toList(); requestModel = ConsultationRequestModel.fromJson(
sbpBanksFromFixture = FixtureReader.readAsList( FixtureReader.readAsMap('consultant/consultation_request.json'),
'consultant/sbp_banks.json', );
).map((item) => SbpBankModel.fromJson(item as Map<String, dynamic>)).toList(); certPhotosFromFixture =
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); FixtureReader.readAsList('consultant/consultant_cert_photos.json')
when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); .map(
(item) => ConsultantCertPhotoModel.fromJson(
item as Map<String, dynamic>,
),
)
.toList();
sbpBanksFromFixture =
FixtureReader.readAsList('consultant/sbp_banks.json')
.map(
(item) => SbpBankModel.fromJson(item as Map<String, dynamic>),
)
.toList();
consultantProfile = ConsultantProfileModel.fromJson(
FixtureReader.readAsMap('consultant/consultant_profile_request.json'),
);
when(
() => repository.getSbpBanks(),
).thenAnswer((_) async => sbpBanksFromFixture);
}); });
tearDown(() async { tearDown(() async {
@@ -50,11 +81,16 @@ void main() {
expect(cubit.state.consultant.hasMedicalEducation, isTrue); expect(cubit.state.consultant.hasMedicalEducation, isTrue);
expect(cubit.state.acceptOffer, isFalse); expect(cubit.state.acceptOffer, isFalse);
expect(cubit.state.requestModel, isNull); expect(cubit.state.requestModel, isNull);
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none);
}); });
test('getMyRequestStatus hydrates state with backend data', () async { test('getMyRequestStatus hydrates state with backend data', () async {
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel); when(
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); () => repository.getMyRequestStatus(any<String>()),
).thenAnswer((_) async => requestModel);
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.getMyRequestStatus('my-consultant-slug');
@@ -62,41 +98,90 @@ void main() {
expect(state.loading, isFalse); expect(state.loading, isFalse);
expect(state.requestModel, equals(requestModel)); expect(state.requestModel, equals(requestModel));
expect(state.consultant.certificates, isNotNull); 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!.length, greaterThan(0));
expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); expect(state.consultant.certificates!.first.title, 'Нутрициология PRO');
expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); expect(
state.consultant.socialLinks?.instaLink,
isNull,
reason: 'empty links are sanitized',
);
expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant');
expect(state.consultant.legalType, 'individualPerson'); expect(state.consultant.legalType, 'individualPerson');
expect(state.consultant.inn, '123456789012'); expect(state.consultant.inn, '123456789012');
expect(state.consultant.passportSeries, '1234'); expect(state.consultant.passportSeries, '1234');
expect(state.consultant.passportIssueDate, '2020-01-15'); expect(state.consultant.passportIssueDate, '2020-01-15');
expect(state.consultant.birthPlace, 'г. Москва'); expect(state.consultant.birthPlace, 'г. Москва');
// All cert photos from backend are kept in state (visible + hidden).
expect(state.certPhotos, hasLength(3)); expect(state.certPhotos, hasLength(3));
expect(state.certPhotos.map((e) => e.id), containsAll(<int>[101, 102, 103])); expect(
state.certPhotos.map((e) => e.id),
containsAll(<int>[101, 102, 103]),
);
expect(state.sbpBanks, hasLength(2)); expect(state.sbpBanks, hasLength(2));
expect(state.sbpBanks.first.bankName, 'Сбербанк'); expect(state.sbpBanks.first.bankName, 'Сбербанк');
verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); expect(state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
expect(state.alfaScoringStatus.canRetry, isTrue);
expect(state.isScoringPolling, isFalse);
expect(
state.awaitingScoringOutcome,
isFalse,
reason: 'hydrate must not arm success sheet',
);
verify(
() => repository.getMyRequestStatus('my-consultant-slug'),
).called(1);
verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getConsultantCertPhotos()).called(1);
verify(() => repository.getSbpBanks()).called(1); verify(() => repository.getSbpBanks()).called(1);
}); });
test('disableLoading stops spinner and loads cert photos', () async { test(
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); '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<String>())).thenAnswer((
_,
) async {
calls++;
return calls == 1 ? pending : complete;
});
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
await cubit.disableLoading(); await cubit.getMyRequestStatus('my-consultant-slug');
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringPending);
expect(cubit.state.isScoringPolling, isTrue);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(
cubit.state.alfaScoringStatus,
AlfaScoringStatus.scoringComplete,
);
expect(cubit.state.isScoringPolling, isFalse);
},
);
test('disableLoading stops spinner and loads cert photos', () async {
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
await cubit.disableLoading(slug: 'my-consultant-slug');
expect(cubit.state.loading, isFalse); expect(cubit.state.loading, isFalse);
// disableLoading now keeps all photos from repository.
expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.certPhotos, hasLength(3));
expect(cubit.state.sbpBanks, hasLength(2)); expect(cubit.state.sbpBanks, hasLength(2));
expect(cubit.userSlug, 'my-consultant-slug');
verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getConsultantCertPhotos()).called(1);
verify(() => repository.getSbpBanks()).called(1); verify(() => repository.getSbpBanks()).called(1);
}); });
test('searchSbpBanks filters banks by name', () async { test('searchSbpBanks filters banks by name', () async {
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
await cubit.disableLoading(); await cubit.disableLoading();
final results = cubit.searchSbpBanks('сбер'); final results = cubit.searchSbpBanks('сбер');
@@ -106,7 +191,9 @@ void main() {
}); });
test('findSbpBankById returns matching bank', () async { test('findSbpBankById returns matching bank', () async {
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
await cubit.disableLoading(); await cubit.disableLoading();
final bank = cubit.findSbpBankById('100000000004'); final bank = cubit.findSbpBankById('100000000004');
@@ -114,42 +201,205 @@ void main() {
expect(bank?.bankName, 'Т-Банк'); expect(bank?.bankName, 'Т-Банк');
}); });
test('updateConsultantProfileModel replaces consultant data and offer flag', () { test(
cubit.updateConsultantProfileModel(consultantProfile, true); 'updateConsultantProfileModel replaces consultant data and offer flag',
() {
cubit.updateConsultantProfileModel(consultantProfile, true);
expect(cubit.state.consultant, consultantProfile); expect(cubit.state.consultant, consultantProfile);
expect(cubit.state.acceptOffer, isTrue); expect(cubit.state.acceptOffer, isTrue);
expect(cubit.state.loading, isFalse); expect(cubit.state.loading, isFalse);
}); },
);
test('registerConsultant forwards payload with update=false by default', () async { test(
when( 'registerConsultant forwards payload with update=false by default',
() => repository.registerConsultant( () async {
consultant: any<ConsultantProfileModel>(named: 'consultant'), when(
update: any<bool>(named: 'update'), () => repository.registerConsultant(
), consultant: any<ConsultantProfileModel>(named: 'consultant'),
).thenAnswer((_) async {}); update: any<bool>(named: 'update'),
),
).thenAnswer((_) async {});
cubit.updateConsultantProfileModel(consultantProfile); cubit.updateConsultantProfileModel(consultantProfile);
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
await cubit.registerConsultant(); await cubit.registerConsultant();
final captured = verify( final captured =
() => repository.registerConsultant( verify(
consultant: captureAny<ConsultantProfileModel>(named: 'consultant'), () => repository.registerConsultant(
update: false, consultant: captureAny<ConsultantProfileModel>(
), named: 'consultant',
).captured.single as ConsultantProfileModel; ),
update: false,
),
).captured.single
as ConsultantProfileModel;
expect(captured.legalType, 'individualPerson'); expect(captured.legalType, 'individualPerson');
expect(captured.agreementAccepted, isTrue); expect(captured.agreementAccepted, isTrue);
expect(captured.frdoAgreementAccepted, isTrue); expect(captured.frdoAgreementAccepted, isTrue);
expect(captured.inn, isNotNull); 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<String>()),
).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<String>()),
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(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<void>.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<String>()),
).thenAnswer((_) async => requestWithScoring('scoring_failed'));
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(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<void>.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<String>())).thenAnswer((
_,
) async {
calls++;
if (calls <= 2) {
return requestWithScoring('none');
}
return requestWithScoring('scoring_complete');
});
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(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<void>.delayed(const Duration(milliseconds: 50));
expect(
cubit.state.alfaScoringStatus,
AlfaScoringStatus.beneficiaryCreating,
);
expect(cubit.state.isScoringPolling, isTrue);
await Future<void>.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 { test('registerConsultant sends legal payload with update=true', () async {
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel); when(
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); () => repository.getMyRequestStatus(any<String>()),
).thenAnswer((_) async => requestModel);
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
when( when(
() => repository.registerConsultant( () => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'), consultant: any<ConsultantProfileModel>(named: 'consultant'),
@@ -160,12 +410,16 @@ void main() {
await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.getMyRequestStatus('my-consultant-slug');
await cubit.registerConsultant(); await cubit.registerConsultant();
final captured = verify( final captured =
() => repository.registerConsultant( verify(
consultant: captureAny<ConsultantProfileModel>(named: 'consultant'), () => repository.registerConsultant(
update: true, consultant: captureAny<ConsultantProfileModel>(
), named: 'consultant',
).captured.single as ConsultantProfileModel; ),
update: true,
),
).captured.single
as ConsultantProfileModel;
final json = captured.toJson(); final json = captured.toJson();
expect(json['legal_type'], 'individualPerson'); expect(json['legal_type'], 'individualPerson');
@@ -175,6 +429,40 @@ void main() {
expect(json.containsKey('frdo_agreement_accepted'), isFalse); expect(json.containsKey('frdo_agreement_accepted'), isFalse);
}); });
test('retryScoring calls update and restarts poll', () async {
when(
() => repository.getMyRequestStatus(any<String>()),
).thenAnswer((_) async => requestModel);
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(named: 'update'),
),
).thenAnswer((_) async {});
await cubit.getMyRequestStatus('my-consultant-slug');
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
when(
() => repository.getMyRequestStatus(any<String>()),
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
await cubit.retryScoring();
verify(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: true,
),
).called(1);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringComplete);
expect(cubit.state.isScoringPolling, isFalse);
});
test('registerConsultant blocks submit without passport data', () async { test('registerConsultant blocks submit without passport data', () async {
when( when(
() => repository.registerConsultant( () => repository.registerConsultant(
@@ -194,18 +482,59 @@ void main() {
); );
}); });
test('registerConsultant maps Laravel 422 into fieldErrors', () async {
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(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 { test('removeCertPhoto marks cert as deleted locally', () async {
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel); when(
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); () => repository.getMyRequestStatus(any<String>()),
when(() => repository.hideConsultantCertPhoto(any<int>())).thenAnswer((_) async {}); ).thenAnswer((_) async => requestModel);
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
when(
() => repository.hideConsultantCertPhoto(any<int>()),
).thenAnswer((_) async {});
await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.getMyRequestStatus('my-consultant-slug');
// All photos from backend are present in state.
expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.certPhotos, hasLength(3));
await cubit.removeCertPhoto(101, null); await cubit.removeCertPhoto(101, null);
// Current implementation only tracks deleted IDs; photos list remains unchanged here.
verifyNever(() => repository.hideConsultantCertPhoto(any<int>())); verifyNever(() => repository.hideConsultantCertPhoto(any<int>()));
expect(cubit.state.deletedPhotoIds, contains(101)); expect(cubit.state.deletedPhotoIds, contains(101));
expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.certPhotos, hasLength(3));
@@ -214,7 +543,9 @@ void main() {
test('uploadCertPhotos - не загружает если список пуст', () async { test('uploadCertPhotos - не загружает если список пуст', () async {
await cubit.addCertPhotos([]); await cubit.addCertPhotos([]);
verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); verifyNever(
() => repository.storeConsultantCertPhotos(files: any(named: 'files')),
);
}); });
test('updateConsultantProfileModel - обновляет без acceptOffer', () { test('updateConsultantProfileModel - обновляет без acceptOffer', () {
@@ -225,4 +556,38 @@ void main() {
expect(cubit.state.loading, isFalse); 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);
});
});
} }

View File

@@ -18,15 +18,28 @@ void main() {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold( home: Scaffold(
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child), body: BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: child,
),
), ),
); );
} }
testWidgets('renders additional settings screen', (tester) async { testWidgets('renders additional settings screen', (tester) async {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); const consultant = ConsultantProfileModel(
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));

View File

@@ -21,12 +21,18 @@ import 'certificates_and_diplomas_screen_smoke_test.mocks.dart';
void main() { void main() {
group('CertificatesAndDiplomasScreen smoke', () { group('CertificatesAndDiplomasScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
}); });
Widget wrap(Widget child) { Widget wrap(Widget child) {
@@ -45,15 +51,30 @@ void main() {
testWidgets('renders certificates and diplomas screen', (tester) async { testWidgets('renders certificates and diplomas screen', (tester) async {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); const consultant = ConsultantProfileModel(
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); await tester.pumpWidget(
BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: wrap(const CertificatesAndDiplomasScreen()),
),
);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
@@ -67,16 +88,28 @@ void main() {
hasMedicalEducation: null, hasMedicalEducation: null,
socialLinks: null, socialLinks: null,
photos: 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); await tester.pumpWidget(
BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: wrap(const CertificatesAndDiplomasScreen()),
),
);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));

View File

@@ -19,50 +19,78 @@ import 'last_step_screen_test.mocks.dart';
void main() { void main() {
group('LastStepScreen smoke', () { group('LastStepScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
}); });
Widget wrap(Widget child, RegisterConsultantCubit cubit) { Widget wrap(Widget child, RegisterConsultantCubit cubit) {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold( home: Scaffold(
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child), body: BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: child,
),
), ),
); );
} }
testWidgets('renders last step screen with both agreements for new registration', (tester) async { testWidgets(
final cubit = MockRegisterConsultantCubit(); 'renders last step screen with both agreements for new registration',
const consultant = ConsultantProfileModel( (tester) async {
hasMedicalEducation: null, final cubit = MockRegisterConsultantCubit();
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), const consultant = ConsultantProfileModel(
photos: null, hasMedicalEducation: null,
certificates: null, socialLinks: SocialLinksModel(
); vkLink: null,
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); await tester.pumpWidget(wrap(const LastStepScreen(), cubit));
await tester.pump(); await tester.pump();
expect(find.byType(LastStepScreen), findsOneWidget); expect(find.byType(LastStepScreen), findsOneWidget);
expect(find.text('Последний шаг'), findsOneWidget); expect(find.text('Последний шаг'), findsOneWidget);
expect(find.byType(CheckboxListTile), findsNWidgets(2)); expect(find.byType(CheckboxListTile), findsNWidgets(2));
expect(find.textContaining('ФРДО'), findsOneWidget); expect(find.textContaining('ФРДО'), findsOneWidget);
}); },
);
testWidgets('hides FRDO checkbox in update mode', (tester) async { testWidgets('hides FRDO checkbox in update mode', (tester) async {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel(); const consultant = ConsultantProfileModel();
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); const state = RegisterConsultantState(
consultant: consultant,
acceptOffer: false,
loading: false,
isUpdating: true,
);
when(cubit.state).thenReturn(state); when(cubit.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));

View File

@@ -16,7 +16,9 @@ void main() {
late RegisterConsultantCubit cubit; late RegisterConsultantCubit cubit;
setUp(() { setUp(() {
cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); cubit = RegisterConsultantCubit(
consultantRepository: MockConsultantRepository(),
);
cubit.emit( cubit.emit(
const RegisterConsultantState( const RegisterConsultantState(
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()),
@@ -35,7 +37,10 @@ void main() {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold( home: Scaffold(
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child), body: BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: child,
),
), ),
); );
} }

View File

@@ -19,12 +19,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart';
void main() { void main() {
group('PhotoOrScansScreen smoke', () { group('PhotoOrScansScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
}); });
Widget wrap(Widget child, RegisterConsultantCubit cubit) { Widget wrap(Widget child, RegisterConsultantCubit cubit) {
@@ -38,7 +44,10 @@ void main() {
], ],
supportedLocales: const [Locale('ru')], supportedLocales: const [Locale('ru')],
home: Scaffold( home: Scaffold(
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child), body: BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: child,
),
), ),
); );
} }
@@ -49,11 +58,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -61,7 +81,10 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PhotoOrScansScreen), findsOneWidget); expect(find.byType(PhotoOrScansScreen), findsOneWidget);
expect(find.text('Загрузите фото или сканы дипломов и сертификатов'), findsOneWidget); expect(
find.text('Загрузите фото или сканы дипломов и сертификатов'),
findsOneWidget,
);
}); });
}); });
@@ -71,11 +94,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);

View File

@@ -16,7 +16,9 @@ void main() {
late RegisterConsultantCubit cubit; late RegisterConsultantCubit cubit;
setUp(() { setUp(() {
cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); cubit = RegisterConsultantCubit(
consultantRepository: MockConsultantRepository(),
);
cubit.emit( cubit.emit(
const RegisterConsultantState( const RegisterConsultantState(
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()),
@@ -35,7 +37,10 @@ void main() {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold( home: Scaffold(
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child), body: BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: child,
),
), ),
); );
} }

View File

@@ -21,12 +21,18 @@ import 'social_networks_screen_smoke_test.mocks.dart';
void main() { void main() {
group('SocialNetworksScreen smoke', () { group('SocialNetworksScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
}); });
Widget wrap(Widget child) { Widget wrap(Widget child) {
@@ -47,18 +53,34 @@ void main() {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const SocialNetworksScreen()))); await tester.pumpWidget(
BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: wrap(const SocialNetworksScreen()),
),
);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
@@ -69,18 +91,34 @@ void main() {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const SocialNetworksScreen()))); await tester.pumpWidget(
BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: wrap(const SocialNetworksScreen()),
),
);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));

View File

@@ -21,12 +21,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart';
void main() { void main() {
group('StepperBecomeConsultantScreen smoke', () { group('StepperBecomeConsultantScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
}); });
Widget wrap(Widget child) { Widget wrap(Widget child) {
@@ -48,11 +54,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -71,11 +88,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -94,11 +122,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -117,11 +156,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -140,11 +190,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -163,11 +224,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);

View File

@@ -37,42 +37,99 @@ void main() {
setUp(() { setUp(() {
mockService = MockConsultantService(); mockService = MockConsultantService();
repository = ConsultantRepository(consultantService: mockService); repository = ConsultantRepository(consultantService: mockService);
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); consultantProfile = ConsultantProfileModel.fromJson(
consultationRequest = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); FixtureReader.readAsMap('consultant/consultant_profile_request.json'),
certPhotos = FixtureReader.readAsList('consultant/consultant_cert_photos.json') );
.map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>)) consultationRequest = ConsultationRequestModel.fromJson(
.toList(); FixtureReader.readAsMap('consultant/consultation_request.json'),
);
certPhotos =
FixtureReader.readAsList('consultant/consultant_cert_photos.json')
.map(
(item) => ConsultantCertPhotoModel.fromJson(
item as Map<String, dynamic>,
),
)
.toList();
}); });
test('registerConsultant delegates to consultantRegistration when update=false', () async { test(
when(() => mockService.consultantRegistration(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))).thenAnswer((_) async {}); 'registerConsultant delegates to consultantRegistration when update=false',
() async {
when(
() => mockService.consultantRegistration(
consultantModel: any<ConsultantProfileModel>(
named: 'consultantModel',
),
),
).thenAnswer((_) async {});
await repository.registerConsultant(consultant: consultantProfile); await repository.registerConsultant(consultant: consultantProfile);
verify(() => mockService.consultantRegistration(consultantModel: consultantProfile)).called(1); verify(
verifyNever(() => mockService.consultantUpdate(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))); () => mockService.consultantRegistration(
}); consultantModel: consultantProfile,
),
).called(1);
verifyNever(
() => mockService.consultantUpdate(
consultantModel: any<ConsultantProfileModel>(
named: 'consultantModel',
),
),
);
},
);
test('registerConsultant delegates to consultantUpdate when update=true', () async { test(
when(() => mockService.consultantUpdate(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))).thenAnswer((_) async {}); 'registerConsultant delegates to consultantUpdate when update=true',
() async {
when(
() => mockService.consultantUpdate(
consultantModel: any<ConsultantProfileModel>(
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); verify(
verifyNever(() => mockService.consultantRegistration(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))); () =>
}); mockService.consultantUpdate(consultantModel: consultantProfile),
).called(1);
verifyNever(
() => mockService.consultantRegistration(
consultantModel: any<ConsultantProfileModel>(
named: 'consultantModel',
),
),
);
},
);
test('getConsultantRequestStatus returns request from service', () async { test('getConsultantRequestStatus returns request from service', () async {
when(() => mockService.getConsultantRequestStatus(slug: any<String>(named: 'slug'))).thenAnswer((_) async => consultationRequest); when(
() => mockService.getConsultantRequestStatus(
slug: any<String>(named: 'slug'),
),
).thenAnswer((_) async => consultationRequest);
final result = await repository.getConsultantRequestStatus('user-slug'); final result = await repository.getConsultantRequestStatus('user-slug');
expect(result, equals(consultationRequest)); 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 { test('getMyRequestStatus returns request from service', () async {
when(() => mockService.getMyRequestStatus(slug: any<String>(named: 'slug'))).thenAnswer((_) async => consultationRequest); when(
() => mockService.getMyRequestStatus(slug: any<String>(named: 'slug')),
).thenAnswer((_) async => consultationRequest);
final result = await repository.getMyRequestStatus('my-slug'); final result = await repository.getMyRequestStatus('my-slug');
@@ -81,7 +138,9 @@ void main() {
}); });
test('getConsultantCertPhotos returns photos from service', () async { test('getConsultantCertPhotos returns photos from service', () async {
when(() => mockService.getConsultantCertPhotos()).thenAnswer((_) async => certPhotos); when(
() => mockService.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotos);
final result = await repository.getConsultantCertPhotos(); final result = await repository.getConsultantCertPhotos();
@@ -101,42 +160,80 @@ void main() {
verify(() => mockService.getSbpBanks()).called(1); verify(() => mockService.getSbpBanks()).called(1);
}); });
test('storeConsultantCertPhotos returns empty list when no files provided', () async { test(
final result = await repository.storeConsultantCertPhotos(files: []); 'storeConsultantCertPhotos returns empty list when no files provided',
() async {
final result = await repository.storeConsultantCertPhotos(files: []);
expect(result, isEmpty); expect(result, isEmpty);
verifyNever(() => mockService.storeConsultantCertPhotos(photos: any<List<MultipartFile>>(named: 'photos'))); verifyNever(
}); () => mockService.storeConsultantCertPhotos(
photos: any<List<MultipartFile>>(named: 'photos'),
),
);
},
);
test('storeConsultantCertPhotos uploads prepared files and returns response', () async { test(
final mockFile = MockFile(); 'storeConsultantCertPhotos uploads prepared files and returns response',
when(() => mockFile.readAsBytes()).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); () async {
when(() => mockFile.path).thenReturn('/tmp/certificate.png'); final mockFile = MockFile();
when(() => mockService.storeConsultantCertPhotos(photos: any<List<MultipartFile>>(named: 'photos'))).thenAnswer((_) async => certPhotos); when(
() => mockFile.readAsBytes(),
).thenAnswer((_) async => Uint8List.fromList([1, 2, 3]));
when(() => mockFile.path).thenReturn('/tmp/certificate.png');
when(
() => mockService.storeConsultantCertPhotos(
photos: any<List<MultipartFile>>(named: 'photos'),
),
).thenAnswer((_) async => certPhotos);
final result = await repository.storeConsultantCertPhotos(files: [mockFile]); final result = await repository.storeConsultantCertPhotos(
files: [mockFile],
);
expect(result, certPhotos); expect(result, certPhotos);
final capturedArgument = verify(() => mockService.storeConsultantCertPhotos(photos: captureAny<List<MultipartFile>>(named: 'photos'))).captured.single as List<MultipartFile>; final capturedArgument =
expect(capturedArgument, hasLength(1)); verify(
expect(capturedArgument.first.filename, 'certificate.png'); () => mockService.storeConsultantCertPhotos(
}); photos: captureAny<List<MultipartFile>>(named: 'photos'),
),
).captured.single
as List<MultipartFile>;
expect(capturedArgument, hasLength(1));
expect(capturedArgument.first.filename, 'certificate.png');
},
);
test('hideConsultantCertPhoto sends payload to service', () async { test('hideConsultantCertPhoto sends payload to service', () async {
when(() => mockService.hideConsultantCertPhoto(payload: any<Map<String, dynamic>>(named: 'payload'))).thenAnswer((_) async {}); when(
() => mockService.hideConsultantCertPhoto(
payload: any<Map<String, dynamic>>(named: 'payload'),
),
).thenAnswer((_) async {});
await repository.hideConsultantCertPhoto(42); 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 { test('unHideConsultantCertPhoto sends payload to service', () async {
when(() => mockService.unHideConsultantCertPhoto(payload: any<Map<String, dynamic>>(named: 'payload'))).thenAnswer((_) async {}); when(
() => mockService.unHideConsultantCertPhoto(
payload: any<Map<String, dynamic>>(named: 'payload'),
),
).thenAnswer((_) async {});
await repository.unHideConsultantCertPhoto(7); await repository.unHideConsultantCertPhoto(7);
verify(() => mockService.unHideConsultantCertPhoto(payload: {'cert_photo_id': 7})).called(1); verify(
() => mockService.unHideConsultantCertPhoto(
payload: {'cert_photo_id': 7},
),
).called(1);
}); });
}); });
} }

View File

@@ -20,11 +20,14 @@ void main() {
test('getMyRequestStatus - возвращает статус запроса', () async { test('getMyRequestStatus - возвращает статус запроса', () async {
const slug = 'test-consultant'; 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); final expectedRequest = ConsultationRequestModel.fromJson(json);
when(mockConsultantService.getMyRequestStatus(slug: slug)) when(
.thenAnswer((_) async => expectedRequest); mockConsultantService.getMyRequestStatus(slug: slug),
).thenAnswer((_) async => expectedRequest);
final result = await mockConsultantService.getMyRequestStatus(slug: slug); final result = await mockConsultantService.getMyRequestStatus(slug: slug);
@@ -33,25 +36,36 @@ void main() {
verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1); verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1);
}); });
test('getConsultantRequestStatus - возвращает статус запроса консультанта', () async { test(
const slug = 'test-consultant'; 'getConsultantRequestStatus - возвращает статус запроса консультанта',
final json = FixtureReader.readAsMap('consultant/consultation_request.json'); () async {
final expectedRequest = ConsultationRequestModel.fromJson(json); const slug = 'test-consultant';
final json = FixtureReader.readAsMap(
'consultant/consultation_request.json',
);
final expectedRequest = ConsultationRequestModel.fromJson(json);
when(mockConsultantService.getConsultantRequestStatus(slug: slug)) when(
.thenAnswer((_) async => expectedRequest); mockConsultantService.getConsultantRequestStatus(slug: slug),
).thenAnswer((_) async => expectedRequest);
final result = await mockConsultantService.getConsultantRequestStatus(slug: slug); final result = await mockConsultantService.getConsultantRequestStatus(
slug: slug,
);
expect(result, isA<ConsultationRequestModel>()); expect(result, isA<ConsultationRequestModel>());
verify(mockConsultantService.getConsultantRequestStatus(slug: slug)).called(1); verify(
}); mockConsultantService.getConsultantRequestStatus(slug: slug),
).called(1);
},
);
test('getConsultantCertPhotos - возвращает фото сертификатов', () async { test('getConsultantCertPhotos - возвращает фото сертификатов', () async {
final expectedPhotos = <ConsultantCertPhotoModel>[]; final expectedPhotos = <ConsultantCertPhotoModel>[];
when(mockConsultantService.getConsultantCertPhotos()) when(
.thenAnswer((_) async => expectedPhotos); mockConsultantService.getConsultantCertPhotos(),
).thenAnswer((_) async => expectedPhotos);
final result = await mockConsultantService.getConsultantCertPhotos(); final result = await mockConsultantService.getConsultantCertPhotos();
@@ -60,16 +74,22 @@ void main() {
}); });
test('consultantRegistration - регистрирует консультанта', () async { 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); final profile = ConsultantProfileModel.fromJson(profileJson);
when(mockConsultantService.consultantRegistration(consultantModel: profile)) when(
.thenAnswer((_) async => Future.value()); 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);
}); });
}); });
} }

View File

@@ -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<String, dynamic> 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.',
),
'Такой СНИЛС существует',
);
});
});
}

View File

@@ -32,7 +32,7 @@
"inn": "123456789012", "inn": "123456789012",
"snils": "123-456-789 00", "snils": "123-456-789 00",
"state": "created", "state": "created",
"alfa_scoring_status": "pending", "alfa_scoring_status": "scoring_failed",
"alfa_scoring_request_id": "abc-123", "alfa_scoring_request_id": "abc-123",
"passport_series": "1234", "passport_series": "1234",
"passport_number": "567890", "passport_number": "567890",