feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint

This commit is contained in:
misha
2026-07-22 17:58:02 +03:00
parent bd2de07b8f
commit 037f153273
23 changed files with 1514 additions and 279 deletions

View File

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

View File

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

View File

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

View File

@@ -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) ?? 'RU',
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 ?? 'RU',
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 ?? 'RU',
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());

View File

@@ -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,

View File

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

View File

@@ -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(
@@ -31,29 +50,56 @@ class _LastStepScreenState extends State<LastStepScreen> {
),
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),
),
),

View File

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

View File

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

View File

@@ -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,258 @@ 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)
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]}'),
],
),
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
? 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 == totalSteps) {
if (widget.isCanceled == true) {
return;
}
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
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
: Text('Продолжить', 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(),
),
);
},
),
);

View File

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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: []);

View File

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

View File

@@ -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
View 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"
}
]