forked from eatvio/eatvio_app
Merge pull request 'feat(consultant_scoring)' (#1005) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1005
This commit is contained in:
@@ -6,24 +6,80 @@ part 'consultant_profile_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
const ConsultantProfileModel._();
|
||||
|
||||
const factory ConsultantProfileModel({
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
|
||||
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
|
||||
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
|
||||
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
|
||||
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
|
||||
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
|
||||
@JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries,
|
||||
@JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber,
|
||||
@JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate,
|
||||
@JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer,
|
||||
@JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode,
|
||||
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
|
||||
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
|
||||
@JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress,
|
||||
@JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress,
|
||||
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
|
||||
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
|
||||
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident,
|
||||
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
|
||||
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId,
|
||||
@JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted,
|
||||
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted,
|
||||
}) = _ConsultantProfileModel;
|
||||
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
final last = lastName ?? '';
|
||||
final first = firstName ?? '';
|
||||
final innValue = inn ?? '';
|
||||
final snilsValue = snils ?? '';
|
||||
final series = passportSeries ?? '';
|
||||
final number = passportNumber ?? '';
|
||||
final issueDate = passportIssueDate ?? '';
|
||||
final issuer = passportIssuer ?? '';
|
||||
final issuerCode = passportIssuerCode ?? '';
|
||||
final birth = birthDate ?? '';
|
||||
final birthPlaceValue = birthPlace ?? '';
|
||||
final registration = registrationAddress ?? '';
|
||||
final residence = residenceAddress ?? '';
|
||||
final citizenshipValue = citizenship ?? '';
|
||||
|
||||
return last.trim().isNotEmpty &&
|
||||
first.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
||||
issuer.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
||||
birthPlaceValue.trim().isNotEmpty &&
|
||||
registration.trim().isNotEmpty &&
|
||||
residence.trim().isNotEmpty &&
|
||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||
const factory SocialLinksModel({
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
||||
}) = _SocialLinksModel;
|
||||
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
||||
|
||||
@@ -12,11 +12,61 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
||||
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
|
||||
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
|
||||
}) = _ConsultationRequestModel;
|
||||
|
||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LegalEntityModel with _$LegalEntityModel {
|
||||
const LegalEntityModel._();
|
||||
|
||||
const factory LegalEntityModel({
|
||||
required int id,
|
||||
required String type,
|
||||
String? inn,
|
||||
String? snils,
|
||||
String? state,
|
||||
@JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus,
|
||||
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
|
||||
@JsonKey(name: 'last_name') String? lastName,
|
||||
@JsonKey(name: 'first_name') String? firstName,
|
||||
@JsonKey(name: 'middle_name') String? middleName,
|
||||
@JsonKey(name: 'passport_series') String? passportSeries,
|
||||
@JsonKey(name: 'passport_number') String? passportNumber,
|
||||
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
|
||||
@JsonKey(name: 'passport_issuer') String? passportIssuer,
|
||||
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
|
||||
@JsonKey(name: 'birth_date') String? birthDate,
|
||||
@JsonKey(name: 'birth_place') String? birthPlace,
|
||||
@JsonKey(name: 'registration_address') String? registrationAddress,
|
||||
@JsonKey(name: 'residence_address') String? residenceAddress,
|
||||
String? citizenship,
|
||||
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
|
||||
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
|
||||
@JsonKey(name: 'sbp_phone') String? sbpPhone,
|
||||
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
|
||||
}) = _LegalEntityModel;
|
||||
|
||||
factory LegalEntityModel.fromJson(Map<String, dynamic> json) => _$LegalEntityModelFromJson(json);
|
||||
|
||||
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
|
||||
static String? normalizeApiDate(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
|
||||
return value.substring(0, 10);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate);
|
||||
|
||||
String? get normalizedBirthDate => normalizeApiDate(birthDate);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||
const factory CertPhotosModel({
|
||||
@@ -29,7 +79,7 @@ abstract class CertPhotosModel with _$CertPhotosModel {
|
||||
enum ModerationStatus {
|
||||
approved,
|
||||
created,
|
||||
canceled
|
||||
canceled,
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'sbp_bank_model.freezed.dart';
|
||||
part 'sbp_bank_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class SbpBankModel with _$SbpBankModel {
|
||||
const factory SbpBankModel({
|
||||
@JsonKey(name: 'bank_member_id') required String bankMemberId,
|
||||
@JsonKey(name: 'bank_name') required String bankName,
|
||||
@JsonKey(name: 'short_name') String? shortName,
|
||||
@JsonKey(name: 'logo_url') String? logoUrl,
|
||||
String? bic,
|
||||
}) = _SbpBankModel;
|
||||
|
||||
factory SbpBankModel.fromJson(Map<String, dynamic> json) => _$SbpBankModelFromJson(json);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -11,21 +13,60 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
final ConsultantRepository consultantRepository;
|
||||
bool _update = false;
|
||||
|
||||
static const String individualPersonLegalType = 'individualPerson';
|
||||
|
||||
RegisterConsultantCubit({required this.consultantRepository})
|
||||
: super(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType),
|
||||
loading: true,
|
||||
acceptOffer: false,
|
||||
acceptFrdoAgreement: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<SbpBankModel>> _loadSbpBanks() async {
|
||||
try {
|
||||
return await consultantRepository.getSbpBanks();
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
return const <SbpBankModel>[];
|
||||
}
|
||||
}
|
||||
|
||||
List<SbpBankModel> searchSbpBanks(String query) {
|
||||
final normalized = query.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return state.sbpBanks;
|
||||
}
|
||||
return state.sbpBanks.where((bank) {
|
||||
final name = bank.bankName.toLowerCase();
|
||||
final shortName = (bank.shortName ?? '').toLowerCase();
|
||||
final bic = (bank.bic ?? '').toLowerCase();
|
||||
return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
SbpBankModel? findSbpBankById(String? bankMemberId) {
|
||||
if (bankMemberId == null || bankMemberId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final bank in state.sbpBanks) {
|
||||
if (bank.bankMemberId == bankMemberId) {
|
||||
return bank;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getMyRequestStatus(String slug) async {
|
||||
try {
|
||||
_update = true;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
|
||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
@@ -37,6 +78,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
return (link != null && link.isNotEmpty) ? link : null;
|
||||
}
|
||||
|
||||
final legal = myRequestStatus.legalEntity;
|
||||
|
||||
emit(
|
||||
RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(
|
||||
@@ -49,11 +92,33 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
|
||||
),
|
||||
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||
legalType: legal?.type ?? individualPersonLegalType,
|
||||
lastName: _emptyToNull(legal?.lastName),
|
||||
firstName: _emptyToNull(legal?.firstName),
|
||||
middleName: _emptyToNull(legal?.middleName),
|
||||
inn: _emptyToNull(legal?.inn),
|
||||
snils: _emptyToNull(legal?.snils),
|
||||
passportSeries: _emptyToNull(legal?.passportSeries),
|
||||
passportNumber: _emptyToNull(legal?.passportNumber),
|
||||
passportIssueDate: legal?.normalizedPassportIssueDate,
|
||||
passportIssuer: _emptyToNull(legal?.passportIssuer),
|
||||
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
|
||||
birthDate: legal?.normalizedBirthDate,
|
||||
birthPlace: _emptyToNull(legal?.birthPlace),
|
||||
registrationAddress: _emptyToNull(legal?.registrationAddress),
|
||||
residenceAddress: _emptyToNull(legal?.residenceAddress),
|
||||
citizenship: _emptyToNull(legal?.citizenship),
|
||||
taxResidentUs: legal?.taxResidentUs ?? false,
|
||||
foreignTaxResident: legal?.foreignTaxResident ?? false,
|
||||
sbpPhone: _emptyToNull(legal?.sbpPhone),
|
||||
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
|
||||
),
|
||||
acceptOffer: false,
|
||||
acceptFrdoAgreement: false,
|
||||
loading: false,
|
||||
requestModel: myRequestStatus,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
isUpdating: _update,
|
||||
error: null,
|
||||
),
|
||||
@@ -65,10 +130,31 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
}
|
||||
}
|
||||
|
||||
String? _emptyToNull(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
Future<void> disableLoading() async {
|
||||
try {
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
error: null,
|
||||
consultant: state.consultant.copyWith(
|
||||
legalType: individualPersonLegalType,
|
||||
citizenship: state.consultant.citizenship,
|
||||
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
@@ -86,13 +172,79 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
}
|
||||
}
|
||||
|
||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||
emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null));
|
||||
}
|
||||
|
||||
bool get hasAcceptedRequiredAgreements {
|
||||
if (_update) {
|
||||
return state.acceptOffer;
|
||||
}
|
||||
return state.acceptOffer && state.acceptFrdoAgreement;
|
||||
}
|
||||
|
||||
ConsultantProfileModel buildSubmitPayload() {
|
||||
final withLegalType = state.consultant.copyWith(
|
||||
legalType: individualPersonLegalType,
|
||||
citizenship: state.consultant.citizenship,
|
||||
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||
certificates: state.consultant.certificates
|
||||
?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (_update) {
|
||||
return ConsultantProfileModel(
|
||||
hasMedicalEducation: withLegalType.hasMedicalEducation,
|
||||
socialLinks: withLegalType.socialLinks,
|
||||
photos: withLegalType.photos,
|
||||
certificates: withLegalType.certificates,
|
||||
legalType: withLegalType.legalType,
|
||||
lastName: withLegalType.lastName,
|
||||
firstName: withLegalType.firstName,
|
||||
middleName: withLegalType.middleName,
|
||||
inn: withLegalType.inn,
|
||||
snils: withLegalType.snils,
|
||||
passportSeries: withLegalType.passportSeries,
|
||||
passportNumber: withLegalType.passportNumber,
|
||||
passportIssueDate: withLegalType.passportIssueDate,
|
||||
passportIssuer: withLegalType.passportIssuer,
|
||||
passportIssuerCode: withLegalType.passportIssuerCode,
|
||||
birthDate: withLegalType.birthDate,
|
||||
birthPlace: withLegalType.birthPlace,
|
||||
registrationAddress: withLegalType.registrationAddress,
|
||||
residenceAddress: withLegalType.residenceAddress,
|
||||
citizenship: withLegalType.citizenship,
|
||||
taxResidentUs: withLegalType.taxResidentUs,
|
||||
foreignTaxResident: withLegalType.foreignTaxResident,
|
||||
sbpPhone: withLegalType.sbpPhone,
|
||||
sbpBankMemberId: withLegalType.sbpBankMemberId,
|
||||
);
|
||||
}
|
||||
|
||||
return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement);
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
emit(state.copyWith(error: null));
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
return;
|
||||
}
|
||||
if (state.newAddedPhotos.isNotEmpty) {
|
||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
||||
}
|
||||
@@ -101,7 +253,7 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
await consultantRepository.deletePhoto(id);
|
||||
}
|
||||
}
|
||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
||||
await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'register_consultant_state.freezed.dart';
|
||||
@@ -12,10 +13,12 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
||||
const factory RegisterConsultantState({
|
||||
required ConsultantProfileModel consultant,
|
||||
required bool acceptOffer,
|
||||
@Default(false) bool acceptFrdoAgreement,
|
||||
required bool loading,
|
||||
required bool isUpdating,
|
||||
ConsultationRequestModel? requestModel,
|
||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||
@Default(<File>[]) List<File> newAddedPhotos,
|
||||
@Default(<int>[]) List<int> deletedPhotoIds,
|
||||
String? error,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||
const CertificatesAndDiplomasScreen({super.key});
|
||||
@@ -231,10 +232,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
void _saveState() {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
||||
0,
|
||||
diplomasList.length - 1,
|
||||
),
|
||||
certificates: diplomasList
|
||||
.map(
|
||||
(e) => CertificatesModel(
|
||||
title: e.textEditingController.text,
|
||||
date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
.sublist(0, diplomasList.length - 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -15,10 +16,28 @@ class LastStepScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LastStepScreenState extends State<LastStepScreen> {
|
||||
bool checkboxState = false;
|
||||
static const _frdoUrl =
|
||||
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
|
||||
|
||||
late final TapGestureRecognizer _frdoTapRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frdoTapRecognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.watch<RegisterConsultantCubit>();
|
||||
final state = cubit.state;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||
child: Column(
|
||||
@@ -29,31 +48,87 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
),
|
||||
if (state.requestModel?.canceledModeration != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33FFCC00),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.requestModel!.canceledModeration!.title,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2),
|
||||
),
|
||||
if (state.requestModel!.canceledModeration!.additional != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
for (final item in state.requestModel!.canceledModeration!.additional!) ...[
|
||||
Text('• $item', style: const TextStyle(fontSize: 14, height: 1.3)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
value: checkboxState,
|
||||
value: state.acceptOffer,
|
||||
title: Text(
|
||||
hidePlans
|
||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
||||
style: const TextStyle(fontSize: 18, height: 1),
|
||||
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
checkboxState = !checkboxState;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (!state.isUpdating) ...[
|
||||
CheckboxListTile(
|
||||
value: state.acceptFrdoAgreement,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)),
|
||||
children: [
|
||||
const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '),
|
||||
TextSpan(
|
||||
text: 'ФРДО',
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: _frdoTapRecognizer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: TextButton(
|
||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
||||
onPressed: () async => await launchUrl(
|
||||
Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf'),
|
||||
),
|
||||
child: Text(
|
||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты',
|
||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
|
||||
class PassportDataScreen extends StatefulWidget {
|
||||
const PassportDataScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PassportDataScreen> createState() => _PassportDataScreenState();
|
||||
}
|
||||
|
||||
class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
late final TextEditingController lastNameController;
|
||||
late final TextEditingController firstNameController;
|
||||
late final TextEditingController middleNameController;
|
||||
late final TextEditingController innController;
|
||||
late final TextEditingController snilsController;
|
||||
late final TextEditingController seriesController;
|
||||
late final TextEditingController numberController;
|
||||
late final TextEditingController issueDateController;
|
||||
late final TextEditingController issuerController;
|
||||
late final TextEditingController issuerCodeController;
|
||||
late final TextEditingController birthDateController;
|
||||
late final TextEditingController birthPlaceController;
|
||||
late final TextEditingController citizenshipController;
|
||||
late final TextEditingController registrationAddressController;
|
||||
late final TextEditingController residenceAddressController;
|
||||
late final TextEditingController sbpPhoneController;
|
||||
late final TextEditingController sbpBankController;
|
||||
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
|
||||
|
||||
bool taxResidentUs = false;
|
||||
bool foreignTaxResident = false;
|
||||
String? selectedSbpBankMemberId;
|
||||
String? selectedSbpBankName;
|
||||
|
||||
final snilsMask = MaskTextInputFormatter(
|
||||
mask: '###-###-### ##',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final dateMaskIssue = MaskTextInputFormatter(
|
||||
mask: '##.##.####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final dateMaskBirth = MaskTextInputFormatter(
|
||||
mask: '##.##.####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final issuerCodeMask = MaskTextInputFormatter(
|
||||
mask: '###-###',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final phoneMask = MaskTextInputFormatter(
|
||||
mask: '+7 (###) ### ## ##',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
|
||||
lastNameController = TextEditingController(text: consultant.lastName ?? '');
|
||||
firstNameController = TextEditingController(
|
||||
text: consultant.firstName ?? '',
|
||||
);
|
||||
middleNameController = TextEditingController(
|
||||
text: consultant.middleName ?? '',
|
||||
);
|
||||
innController = TextEditingController(text: consultant.inn ?? '');
|
||||
snilsController = TextEditingController(text: consultant.snils ?? '');
|
||||
seriesController = TextEditingController(
|
||||
text: consultant.passportSeries ?? '',
|
||||
);
|
||||
numberController = TextEditingController(
|
||||
text: consultant.passportNumber ?? '',
|
||||
);
|
||||
issueDateController = TextEditingController(
|
||||
text: _apiDateToUi(consultant.passportIssueDate),
|
||||
);
|
||||
issuerController = TextEditingController(
|
||||
text: consultant.passportIssuer ?? '',
|
||||
);
|
||||
issuerCodeController = TextEditingController(
|
||||
text: consultant.passportIssuerCode ?? '',
|
||||
);
|
||||
birthDateController = TextEditingController(
|
||||
text: _apiDateToUi(consultant.birthDate),
|
||||
);
|
||||
birthPlaceController = TextEditingController(
|
||||
text: consultant.birthPlace ?? '',
|
||||
);
|
||||
citizenshipController = TextEditingController(
|
||||
text: consultant.citizenship,
|
||||
);
|
||||
registrationAddressController = TextEditingController(
|
||||
text: consultant.registrationAddress ?? '',
|
||||
);
|
||||
residenceAddressController = TextEditingController(
|
||||
text: consultant.residenceAddress ?? '',
|
||||
);
|
||||
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
|
||||
selectedSbpBankMemberId = consultant.sbpBankMemberId;
|
||||
final selectedBank = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.findSbpBankById(consultant.sbpBankMemberId);
|
||||
selectedSbpBankName = selectedBank?.bankName;
|
||||
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
|
||||
taxResidentUs = consultant.taxResidentUs ?? false;
|
||||
foreignTaxResident = consultant.foreignTaxResident ?? false;
|
||||
}
|
||||
|
||||
void _hydrateSbpBankNameIfNeeded() {
|
||||
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (sbpBankController.text.trim().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
final selectedBank = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.findSbpBankById(selectedSbpBankMemberId);
|
||||
if (selectedBank == null) {
|
||||
return;
|
||||
}
|
||||
selectedSbpBankName = selectedBank.bankName;
|
||||
sbpBankController.text = selectedBank.bankName;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
lastNameController.dispose();
|
||||
firstNameController.dispose();
|
||||
middleNameController.dispose();
|
||||
innController.dispose();
|
||||
snilsController.dispose();
|
||||
seriesController.dispose();
|
||||
numberController.dispose();
|
||||
issueDateController.dispose();
|
||||
issuerController.dispose();
|
||||
issuerCodeController.dispose();
|
||||
birthDateController.dispose();
|
||||
birthPlaceController.dispose();
|
||||
citizenshipController.dispose();
|
||||
registrationAddressController.dispose();
|
||||
residenceAddressController.dispose();
|
||||
sbpPhoneController.dispose();
|
||||
sbpBankController.dispose();
|
||||
sbpBankSuggestionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _apiDateToUi(String? apiDate) {
|
||||
if (apiDate == null || apiDate.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final parts = apiDate.split('-');
|
||||
if (parts.length != 3) {
|
||||
return apiDate;
|
||||
}
|
||||
return '${parts[2]}.${parts[1]}.${parts[0]}';
|
||||
}
|
||||
|
||||
String? _uiDateToApi(String uiDate) {
|
||||
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length != 8) {
|
||||
return null;
|
||||
}
|
||||
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
|
||||
}
|
||||
|
||||
String? _uiPhoneToApi(String uiPhone) {
|
||||
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (digits.length == 11 && digits.startsWith('7')) {
|
||||
return '+$digits';
|
||||
}
|
||||
if (digits.length == 10) {
|
||||
return '+7$digits';
|
||||
}
|
||||
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
|
||||
}
|
||||
|
||||
void _syncToCubit() {
|
||||
final cubit = context.read<RegisterConsultantCubit>();
|
||||
final current = cubit.state.consultant;
|
||||
cubit.updateConsultantProfileModel(
|
||||
current.copyWith(
|
||||
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||
lastName: lastNameController.text.trim().isEmpty
|
||||
? null
|
||||
: lastNameController.text.trim(),
|
||||
firstName: firstNameController.text.trim().isEmpty
|
||||
? null
|
||||
: firstNameController.text.trim(),
|
||||
middleName: middleNameController.text.trim().isEmpty
|
||||
? null
|
||||
: middleNameController.text.trim(),
|
||||
inn: innController.text.trim().isEmpty
|
||||
? null
|
||||
: innController.text.trim(),
|
||||
snils: snilsController.text.trim().isEmpty
|
||||
? null
|
||||
: snilsController.text.trim(),
|
||||
passportSeries: seriesController.text.trim().isEmpty
|
||||
? null
|
||||
: seriesController.text.trim(),
|
||||
passportNumber: numberController.text.trim().isEmpty
|
||||
? null
|
||||
: numberController.text.trim(),
|
||||
passportIssueDate: _uiDateToApi(issueDateController.text),
|
||||
passportIssuer: issuerController.text.trim().isEmpty
|
||||
? null
|
||||
: issuerController.text.trim(),
|
||||
passportIssuerCode: issuerCodeController.text.trim().isEmpty
|
||||
? null
|
||||
: issuerCodeController.text.trim(),
|
||||
birthDate: _uiDateToApi(birthDateController.text),
|
||||
birthPlace: birthPlaceController.text.trim().isEmpty
|
||||
? null
|
||||
: birthPlaceController.text.trim(),
|
||||
citizenship: citizenshipController.text.trim().isEmpty
|
||||
? 'RU'
|
||||
: citizenshipController.text.trim().toUpperCase(),
|
||||
registrationAddress: registrationAddressController.text.trim().isEmpty
|
||||
? null
|
||||
: registrationAddressController.text.trim(),
|
||||
residenceAddress: residenceAddressController.text.trim().isEmpty
|
||||
? null
|
||||
: residenceAddressController.text.trim(),
|
||||
taxResidentUs: taxResidentUs,
|
||||
foreignTaxResident: foreignTaxResident,
|
||||
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
|
||||
sbpBankMemberId: selectedSbpBankMemberId,
|
||||
),
|
||||
cubit.state.acceptOffer,
|
||||
cubit.state.acceptFrdoAgreement,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectSbpBank(SbpBankModel bank) {
|
||||
setState(() {
|
||||
selectedSbpBankMemberId = bank.bankMemberId;
|
||||
selectedSbpBankName = bank.bankName;
|
||||
sbpBankController.text = bank.bankName;
|
||||
});
|
||||
_syncToCubit();
|
||||
}
|
||||
|
||||
void _clearSbpBankIfNeeded(String value) {
|
||||
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
|
||||
setState(() {
|
||||
selectedSbpBankMemberId = null;
|
||||
selectedSbpBankName = null;
|
||||
});
|
||||
_syncToCubit();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
|
||||
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
left: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Паспортные данные',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
_sectionTitle('Персональные данные (как в паспорте)'),
|
||||
CustomTextFormField(
|
||||
hintText: 'Фамилия',
|
||||
controller: lastNameController,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
_sectionTitle('Идентификация'),
|
||||
CustomTextFormField(
|
||||
hintText: 'ИНН (12 цифр)',
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'СНИЛС',
|
||||
controller: snilsController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [snilsMask],
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
|
||||
controller: birthDateController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [dateMaskBirth],
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Место рождения',
|
||||
controller: birthPlaceController,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Гражданство (ISO, например RU)',
|
||||
controller: citizenshipController,
|
||||
maxLength: 2,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Адрес регистрации',
|
||||
controller: registrationAddressController,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextFormField(
|
||||
hintText: 'Адрес проживания',
|
||||
controller: residenceAddressController,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: taxResidentUs,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: appTheme.blueA400,
|
||||
title: const Text(
|
||||
'Я являюсь налоговым резидентом США',
|
||||
style: TextStyle(fontSize: 16, height: 1.2),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => taxResidentUs = value ?? false);
|
||||
_syncToCubit();
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: foreignTaxResident,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: appTheme.blueA400,
|
||||
title: const Text(
|
||||
'Я являюсь иностранным налоговым резидентом',
|
||||
style: TextStyle(fontSize: 16, height: 1.2),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => foreignTaxResident = value ?? false);
|
||||
_syncToCubit();
|
||||
},
|
||||
),
|
||||
_sectionTitle('Паспорт'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Серия',
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Номер',
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegistrationAsScreen extends StatefulWidget {
|
||||
const RegistrationAsScreen({super.key});
|
||||
@@ -11,7 +13,23 @@ class RegistrationAsScreen extends StatefulWidget {
|
||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||
|
||||
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
||||
static const RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final cubit = context.read<RegisterConsultantCubit>();
|
||||
cubit.updateConsultantProfileModel(
|
||||
cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType),
|
||||
cubit.state.acceptOffer,
|
||||
cubit.state.acceptFrdoAgreement,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,11 +54,7 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: (_) {},
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
@@ -58,16 +72,12 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -80,16 +90,12 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -102,16 +108,12 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||
@@ -28,7 +29,7 @@ class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 1 / 6;
|
||||
double progressValue = 0;
|
||||
int step = 1;
|
||||
final pageController = PageController();
|
||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||
@@ -44,6 +45,20 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
}
|
||||
}
|
||||
|
||||
static const int totalSteps = 7;
|
||||
|
||||
List<Widget> _pages() {
|
||||
return const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
PassportDataScreen(),
|
||||
LastStepScreen(),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
@@ -53,7 +68,7 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null) {
|
||||
if (state.error != null && state.requestModel == null && widget.slug != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
@@ -80,240 +95,261 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
},
|
||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
},
|
||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
||||
),
|
||||
if (widget.isCanceled == true)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(state.requestModel!.canceledModeration!.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration = state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: isLoading
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
if (step == 6) {
|
||||
if (widget.isCanceled == true) {
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit.state.acceptOffer) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error != null) {
|
||||
return;
|
||||
}
|
||||
await showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: isLoading
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: const Text('Заполните паспортные данные'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error != null) {
|
||||
return;
|
||||
}
|
||||
await showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: const Text('Примите договор публичной офёрты'),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
label: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true
|
||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
||||
),
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(begin: 1 / totalSteps, end: progressValue),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / 6;
|
||||
});
|
||||
},
|
||||
children: const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
LastStepScreen(),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
@@ -17,22 +18,24 @@ class ConsultantRepository {
|
||||
} else {
|
||||
return await consultantService.consultantRegistration(consultantModel: consultant);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
|
||||
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
|
||||
return await consultantService.getConsultantRequestStatus(slug: slug);
|
||||
|
||||
}
|
||||
|
||||
/// Своя заявка — education + legal/passport для регистрации и update.
|
||||
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
||||
return await consultantService.getMyRequestStatus(slug: slug);
|
||||
}
|
||||
|
||||
Future<List<SbpBankModel>> getSbpBanks() async {
|
||||
return await consultantService.getSbpBanks();
|
||||
}
|
||||
|
||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
||||
return await consultantService.getConsultantCertPhotos();
|
||||
|
||||
}
|
||||
|
||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
|
||||
@@ -49,21 +52,17 @@ class ConsultantRepository {
|
||||
}
|
||||
|
||||
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
|
||||
|
||||
}
|
||||
|
||||
Future<void> hideConsultantCertPhoto(int id) async {
|
||||
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
||||
|
||||
}
|
||||
|
||||
Future<void> unHideConsultantCertPhoto(int id) async {
|
||||
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
||||
|
||||
}
|
||||
|
||||
Future<void> deletePhoto(int id) async {
|
||||
await consultantService.deletePhoto(id: id);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
part 'consultant_service.g.dart';
|
||||
@@ -10,22 +11,29 @@ part 'consultant_service.g.dart';
|
||||
abstract class ConsultantService {
|
||||
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
||||
|
||||
@POST('/api/consultant_request/registration')
|
||||
@POST('/api/v2/consultant_request/registration')
|
||||
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
|
||||
|
||||
@POST('/api/consultant_request/photos/upload')
|
||||
@MultiPart()
|
||||
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
|
||||
|
||||
/// Публичный статус консультанта для просмотра профиля.
|
||||
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
|
||||
@GET('/api/profile/consultant/{slug}/status')
|
||||
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
|
||||
|
||||
@GET('/api/consultant_request/{slug}/status')
|
||||
/// Свой статус заявки для редактирования/модерации.
|
||||
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
|
||||
@GET('/api/v2/consultant_request/{slug}/status')
|
||||
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
|
||||
|
||||
@PUT('/api/consultant_request/update')
|
||||
@PUT('/api/v2/consultant_request/update')
|
||||
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
|
||||
|
||||
@GET('/api/finances/sbp_banks')
|
||||
Future<List<SbpBankModel>> getSbpBanks();
|
||||
|
||||
@GET('/api/v2/consultant_request/cert_photos')
|
||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
||||
|
||||
|
||||
@@ -106,7 +106,17 @@ void main() {
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
|
||||
const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01');
|
||||
const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]);
|
||||
const model = ConsultantProfileModel(
|
||||
hasMedicalEducation: true,
|
||||
socialLinks: socialLinks,
|
||||
photos: ['photo1.jpg'],
|
||||
certificates: [certificates],
|
||||
legalType: 'individualPerson',
|
||||
inn: '123456789012',
|
||||
snils: '123-456-789 00',
|
||||
agreementAccepted: true,
|
||||
frdoAgreementAccepted: true,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -114,6 +124,34 @@ void main() {
|
||||
expect(json['social_links'], isNotNull);
|
||||
expect(json['cert_photos'], ['photo1.jpg']);
|
||||
expect(json['completed_courses'], isNotNull);
|
||||
expect(json['legal_type'], 'individualPerson');
|
||||
expect(json['inn'], '123456789012');
|
||||
expect(json['snils'], '123-456-789 00');
|
||||
expect(json['agreement_accepted'], isTrue);
|
||||
expect(json['frdo_agreement_accepted'], isTrue);
|
||||
});
|
||||
|
||||
test('hasValidPassportData validates required individualPerson fields', () {
|
||||
const valid = ConsultantProfileModel(
|
||||
lastName: 'Иванов',
|
||||
firstName: 'Иван',
|
||||
inn: '123456789012',
|
||||
snils: '123-456-789 00',
|
||||
passportSeries: '1234',
|
||||
passportNumber: '567890',
|
||||
passportIssueDate: '2020-01-15',
|
||||
passportIssuer: 'ОВД',
|
||||
passportIssuerCode: '772-053',
|
||||
birthDate: '1990-05-20',
|
||||
birthPlace: 'Москва',
|
||||
registrationAddress: 'Адрес 1',
|
||||
residenceAddress: 'Адрес 2',
|
||||
citizenship: 'RU',
|
||||
);
|
||||
|
||||
expect(valid.hasValidPassportData, isTrue);
|
||||
expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse);
|
||||
expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +122,47 @@ void main() {
|
||||
expect(model.canceledModeration!.status, ModerationStatus.canceled);
|
||||
});
|
||||
|
||||
test('fromJson parses legal_entity_resource', () {
|
||||
final json = {
|
||||
'rating_place': 5,
|
||||
'education_resource': {'request_state': 'approved'},
|
||||
'cert_photos': [],
|
||||
'legal_entity_resource': {
|
||||
'id': 1,
|
||||
'type': 'individualPerson',
|
||||
'inn': '123456789012',
|
||||
'snils': '123-456-789 00',
|
||||
'state': 'created',
|
||||
'alfa_scoring_status': 'pending',
|
||||
'alfa_scoring_request_id': 'abc',
|
||||
'passport_series': '1234',
|
||||
'passport_number': '567890',
|
||||
'passport_issue_date': '2020-01-15T00:00:00.000000Z',
|
||||
'passport_issuer': 'ОВД',
|
||||
'passport_issuer_code': '772-053',
|
||||
'birth_date': '1990-05-20',
|
||||
'birth_place': 'Москва',
|
||||
'registration_address': 'Адрес 1',
|
||||
'residence_address': 'Адрес 2',
|
||||
},
|
||||
};
|
||||
|
||||
final model = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
expect(model.legalEntity, isNotNull);
|
||||
expect(model.legalEntity!.type, 'individualPerson');
|
||||
expect(model.legalEntity!.inn, '123456789012');
|
||||
expect(model.legalEntity!.passportSeries, '1234');
|
||||
expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15');
|
||||
expect(model.legalEntity!.alfaScoringStatus, 'pending');
|
||||
});
|
||||
|
||||
test('normalizeApiDate strips time from DateTime.toString format', () {
|
||||
expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22');
|
||||
expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22');
|
||||
expect(LegalEntityModel.normalizeApiDate(null), isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes ConsultationRequestModel correctly', () {
|
||||
const educationModel = EducationModel(requestState: ModerationStatus.approved);
|
||||
const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -23,6 +24,7 @@ void main() {
|
||||
late RegisterConsultantCubit cubit;
|
||||
late ConsultationRequestModel requestModel;
|
||||
late List<ConsultantCertPhotoModel> certPhotosFromFixture;
|
||||
late List<SbpBankModel> sbpBanksFromFixture;
|
||||
late ConsultantProfileModel consultantProfile;
|
||||
|
||||
setUp(() {
|
||||
@@ -32,7 +34,11 @@ void main() {
|
||||
certPhotosFromFixture = FixtureReader.readAsList(
|
||||
'consultant/consultant_cert_photos.json',
|
||||
).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 {
|
||||
@@ -61,11 +67,19 @@ void main() {
|
||||
expect(state.consultant.certificates!.first.title, 'Нутрициология PRO');
|
||||
expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized');
|
||||
expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant');
|
||||
expect(state.consultant.legalType, 'individualPerson');
|
||||
expect(state.consultant.inn, '123456789012');
|
||||
expect(state.consultant.passportSeries, '1234');
|
||||
expect(state.consultant.passportIssueDate, '2020-01-15');
|
||||
expect(state.consultant.birthPlace, 'г. Москва');
|
||||
// All cert photos from backend are kept in state (visible + hidden).
|
||||
expect(state.certPhotos, hasLength(3));
|
||||
expect(state.certPhotos.map((e) => e.id), containsAll(<int>[101, 102, 103]));
|
||||
expect(state.sbpBanks, hasLength(2));
|
||||
expect(state.sbpBanks.first.bankName, 'Сбербанк');
|
||||
verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1);
|
||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||
verify(() => repository.getSbpBanks()).called(1);
|
||||
});
|
||||
|
||||
test('disableLoading stops spinner and loads cert photos', () async {
|
||||
@@ -76,7 +90,28 @@ void main() {
|
||||
expect(cubit.state.loading, isFalse);
|
||||
// disableLoading now keeps all photos from repository.
|
||||
expect(cubit.state.certPhotos, hasLength(3));
|
||||
expect(cubit.state.sbpBanks, hasLength(2));
|
||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||
verify(() => repository.getSbpBanks()).called(1);
|
||||
});
|
||||
|
||||
test('searchSbpBanks filters banks by name', () async {
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
await cubit.disableLoading();
|
||||
|
||||
final results = cubit.searchSbpBanks('сбер');
|
||||
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.bankMemberId, '100000000111');
|
||||
});
|
||||
|
||||
test('findSbpBankById returns matching bank', () async {
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
await cubit.disableLoading();
|
||||
|
||||
final bank = cubit.findSbpBankById('100000000004');
|
||||
|
||||
expect(bank?.bankName, 'Т-Банк');
|
||||
});
|
||||
|
||||
test('updateConsultantProfileModel replaces consultant data and offer flag', () {
|
||||
@@ -95,12 +130,24 @@ void main() {
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
cubit.updateConsultantProfileModel(consultantProfile);
|
||||
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||
await cubit.registerConsultant();
|
||||
|
||||
verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1);
|
||||
final captured = verify(
|
||||
() => repository.registerConsultant(
|
||||
consultant: captureAny<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: false,
|
||||
),
|
||||
).captured.single as ConsultantProfileModel;
|
||||
|
||||
expect(captured.legalType, 'individualPerson');
|
||||
expect(captured.agreementAccepted, isTrue);
|
||||
expect(captured.frdoAgreementAccepted, isTrue);
|
||||
expect(captured.inn, isNotNull);
|
||||
});
|
||||
|
||||
test('registerConsultant sends update=true after getMyRequestStatus call', () async {
|
||||
test('registerConsultant sends legal payload with update=true', () async {
|
||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
@@ -113,7 +160,38 @@ void main() {
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
await cubit.registerConsultant();
|
||||
|
||||
verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1);
|
||||
final captured = verify(
|
||||
() => repository.registerConsultant(
|
||||
consultant: captureAny<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: true,
|
||||
),
|
||||
).captured.single as ConsultantProfileModel;
|
||||
|
||||
final json = captured.toJson();
|
||||
expect(json['legal_type'], 'individualPerson');
|
||||
expect(json['inn'], '123456789012');
|
||||
expect(json['passport_series'], '1234');
|
||||
expect(json.containsKey('agreement_accepted'), isFalse);
|
||||
expect(json.containsKey('frdo_agreement_accepted'), isFalse);
|
||||
});
|
||||
|
||||
test('registerConsultant blocks submit without passport data', () async {
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.registerConsultant();
|
||||
|
||||
expect(cubit.state.error, 'Заполните паспортные данные');
|
||||
verifyNever(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('removeCertPhoto marks cert as deleted locally', () async {
|
||||
|
||||
@@ -36,7 +36,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders last step screen', (tester) async {
|
||||
testWidgets('renders last step screen with both agreements for new registration', (tester) async {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
@@ -49,13 +49,30 @@ void main() {
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateConsultantProfileModel(any, any)).thenReturn(null);
|
||||
|
||||
await tester.pumpWidget(wrap(const LastStepScreen(), cubit));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LastStepScreen), findsOneWidget);
|
||||
expect(find.text('Последний шаг'), findsOneWidget);
|
||||
expect(find.byType(CheckboxListTile), findsNWidgets(2));
|
||||
expect(find.textContaining('ФРДО'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides FRDO checkbox in update mode', (tester) async {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel();
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(wrap(const LastStepScreen(), cubit));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CheckboxListTile), findsOneWidget);
|
||||
expect(find.textContaining('ФРДО'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockConsultantRepository extends Mock implements ConsultantRepository {}
|
||||
|
||||
void main() {
|
||||
group('PassportDataScreen smoke', () {
|
||||
late RegisterConsultantCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository());
|
||||
cubit.emit(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()),
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders passport data fields', (tester) async {
|
||||
await tester.pumpWidget(wrap(const PassportDataScreen()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PassportDataScreen), findsOneWidget);
|
||||
expect(find.text('Паспортные данные'), findsOneWidget);
|
||||
expect(find.text('Фамилия'), findsOneWidget);
|
||||
expect(find.text('Имя'), findsOneWidget);
|
||||
expect(find.text('ИНН (12 цифр)'), findsOneWidget);
|
||||
expect(find.text('СНИЛС'), findsOneWidget);
|
||||
expect(find.text('Гражданство (ISO, например RU)'), findsOneWidget);
|
||||
expect(find.text('Телефон для выплат через СБП'), findsOneWidget);
|
||||
expect(find.text('Банк получатель (поиск)'), findsOneWidget);
|
||||
expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,54 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockConsultantRepository extends Mock implements ConsultantRepository {}
|
||||
|
||||
void main() {
|
||||
group('RegistrationAsScreen smoke', () {
|
||||
late RegisterConsultantCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository());
|
||||
cubit.emit(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()),
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(body: child),
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders registration as screen', (tester) async {
|
||||
await tester.pumpWidget(wrap(const RegistrationAsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(RegistrationAsScreen), findsOneWidget);
|
||||
expect(find.text('Регистрация в качестве'), findsOneWidget);
|
||||
expect(find.text('Физического лица'), findsOneWidget);
|
||||
expect(cubit.state.consultant.legalType, 'individualPerson');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('шаг 1 из 6'), findsOneWidget);
|
||||
expect(find.text('шаг 1 из 7'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays progress bar', (tester) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -88,6 +89,18 @@ void main() {
|
||||
verify(() => mockService.getConsultantCertPhotos()).called(1);
|
||||
});
|
||||
|
||||
test('getSbpBanks returns banks from service', () async {
|
||||
final banks = FixtureReader.readAsList('consultant/sbp_banks.json')
|
||||
.map((item) => SbpBankModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
when(() => mockService.getSbpBanks()).thenAnswer((_) async => banks);
|
||||
|
||||
final result = await repository.getSbpBanks();
|
||||
|
||||
expect(result, banks);
|
||||
verify(() => mockService.getSbpBanks()).called(1);
|
||||
});
|
||||
|
||||
test('storeConsultantCertPhotos returns empty list when no files provided', () async {
|
||||
final result = await repository.storeConsultantCertPhotos(files: []);
|
||||
|
||||
|
||||
@@ -20,6 +20,26 @@
|
||||
"title": "Психология питания",
|
||||
"date": "2023-06-05"
|
||||
}
|
||||
]
|
||||
],
|
||||
"legal_type": "individualPerson",
|
||||
"last_name": "Иванов",
|
||||
"first_name": "Иван",
|
||||
"middle_name": "Иванович",
|
||||
"inn": "123456789012",
|
||||
"snils": "123-456-789 00",
|
||||
"passport_series": "1234",
|
||||
"passport_number": "567890",
|
||||
"passport_issue_date": "2020-01-15",
|
||||
"passport_issuer": "ОВД Пресненского района г. Москвы",
|
||||
"passport_issuer_code": "772-053",
|
||||
"birth_date": "1990-05-20",
|
||||
"birth_place": "г. Москва",
|
||||
"registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"citizenship": "RU",
|
||||
"tax_resident_us": false,
|
||||
"foreign_tax_resident": false,
|
||||
"sbp_phone": "+79991234567",
|
||||
"agreement_accepted": true,
|
||||
"frdo_agreement_accepted": true
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,31 @@
|
||||
"personal_site_link": "https://consultant.eatvio.app",
|
||||
"request_state": "approved"
|
||||
},
|
||||
"legal_entity_resource": {
|
||||
"id": 1,
|
||||
"type": "individualPerson",
|
||||
"last_name": "Иванов",
|
||||
"first_name": "Иван",
|
||||
"middle_name": "Иванович",
|
||||
"inn": "123456789012",
|
||||
"snils": "123-456-789 00",
|
||||
"state": "created",
|
||||
"alfa_scoring_status": "pending",
|
||||
"alfa_scoring_request_id": "abc-123",
|
||||
"passport_series": "1234",
|
||||
"passport_number": "567890",
|
||||
"passport_issue_date": "2020-01-15",
|
||||
"passport_issuer": "ОВД Пресненского района г. Москвы",
|
||||
"passport_issuer_code": "772-053",
|
||||
"birth_date": "1990-05-20",
|
||||
"birth_place": "г. Москва",
|
||||
"registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"citizenship": "RU",
|
||||
"tax_resident_us": false,
|
||||
"foreign_tax_resident": false,
|
||||
"sbp_phone": "+79991234567"
|
||||
},
|
||||
"cert_photos": [
|
||||
{
|
||||
"url": "https://cdn.eatvio.app/certificates/visible.jpg"
|
||||
@@ -40,4 +65,3 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
test/fixtures/consultant/sbp_banks.json
vendored
Normal file
16
test/fixtures/consultant/sbp_banks.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"bank_member_id": "100000000111",
|
||||
"bank_name": "Сбербанк",
|
||||
"short_name": "Сбер",
|
||||
"logo_url": "https://example.com/sber.png",
|
||||
"bic": "044525225"
|
||||
},
|
||||
{
|
||||
"bank_member_id": "100000000004",
|
||||
"bank_name": "Т-Банк",
|
||||
"short_name": "ТБанк",
|
||||
"logo_url": "https://example.com/tbank.png",
|
||||
"bic": "044525974"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user