merge upstream

This commit is contained in:
2026-08-04 10:17:32 +00:00
88 changed files with 6449 additions and 1957 deletions

View File

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

View File

@@ -1,4 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
@@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
final CommentsCubit commentsCubit = sl<CommentsCubit>();
final textEditingController = TextEditingController();
int? replyId;
int currentGalleryIndex = 0;
@override
void initState() {
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
super.initState();
}
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
}
@override
Widget build(BuildContext context) {
return BlocProvider(
@@ -242,8 +250,29 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
),
const SizedBox(height: 8),
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
Builder(
builder: (context) {
final post = blogPostState.blogPostModel;
if (post == null) {
return const SizedBox.shrink();
}
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
return BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery,
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(

View File

@@ -0,0 +1,51 @@
import 'package:freezed_annotation/freezed_annotation.dart';
enum AlfaScoringStatus {
@JsonValue('none')
none,
@JsonValue('beneficiary_creating')
beneficiaryCreating,
@JsonValue('beneficiary_failed')
beneficiaryFailed,
@JsonValue('scoring_pending')
scoringPending,
@JsonValue('scoring_complete')
scoringComplete,
@JsonValue('scoring_failed')
scoringFailed;
static AlfaScoringStatus fromString(String? value) {
switch (value) {
case 'beneficiary_creating':
return AlfaScoringStatus.beneficiaryCreating;
case 'beneficiary_failed':
return AlfaScoringStatus.beneficiaryFailed;
case 'scoring_pending':
return AlfaScoringStatus.scoringPending;
case 'scoring_complete':
return AlfaScoringStatus.scoringComplete;
case 'scoring_failed':
return AlfaScoringStatus.scoringFailed;
case 'none':
case null:
case '':
return AlfaScoringStatus.none;
default:
return AlfaScoringStatus.none;
}
}
bool get isProcessing =>
this == AlfaScoringStatus.beneficiaryCreating ||
this == AlfaScoringStatus.scoringPending;
bool get isFailed =>
this == AlfaScoringStatus.beneficiaryFailed ||
this == AlfaScoringStatus.scoringFailed;
bool get canRetry => isFailed;
bool get isComplete => this == AlfaScoringStatus.scoringComplete;
bool get isTerminal => isComplete || isFailed;
}

View File

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

View File

@@ -6,27 +6,100 @@ part 'consultant_profile_model.g.dart';
@freezed
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
const ConsultantProfileModel._();
const factory ConsultantProfileModel({
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<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);
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);
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
_$SocialLinksModelFromJson(json);
}
@freezed
@@ -36,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel {
required String date,
}) = _CertificatesModel;
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
_$CertificatesModelFromJson(json);
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
@JsonKey(name: 'rating_place') int? ratingPlace,
@JsonKey(name: 'education_resource') required EducationModel educationModel,
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
@JsonKey(name: 'canceled_moderation')
CanceledModerationModel? canceledModeration,
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
}) = _ConsultationRequestModel;
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
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',
unknownEnumValue: AlfaScoringStatus.none,
defaultValue: AlfaScoringStatus.none,
)
@Default(AlfaScoringStatus.none)
AlfaScoringStatus alfaScoringStatus,
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
@JsonKey(name: 'last_name') String? lastName,
@JsonKey(name: 'first_name') String? firstName,
@JsonKey(name: 'middle_name') String? middleName,
@JsonKey(name: 'passport_series') String? passportSeries,
@JsonKey(name: 'passport_number') String? passportNumber,
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
@JsonKey(name: 'passport_issuer') String? passportIssuer,
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
@JsonKey(name: 'birth_date') String? birthDate,
@JsonKey(name: 'birth_place') String? birthPlace,
@JsonKey(name: 'registration_address') String? registrationAddress,
@JsonKey(name: 'residence_address') String? residenceAddress,
String? citizenship,
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
@JsonKey(name: 'sbp_phone') String? sbpPhone,
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
}) = _LegalEntityModel;
factory LegalEntityModel.fromJson(Map<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({
required String url,
}) = _CertPhotosModel;
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
_$CertPhotosModelFromJson(json);
}
enum ModerationStatus {
approved,
created,
canceled
}
enum ModerationStatus { approved, created, canceled }
@freezed
abstract class CanceledModerationModel with _$CanceledModerationModel {
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
required List<String>? additional,
}) = _CanceledModerationModel;
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
_$CanceledModerationModelFromJson(json);
}

View File

@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'sbp_bank_model.freezed.dart';
part 'sbp_bank_model.g.dart';
@freezed
abstract class SbpBankModel with _$SbpBankModel {
const factory SbpBankModel({
@JsonKey(name: 'bank_member_id') required String bankMemberId,
@JsonKey(name: 'bank_name') required String bankName,
@JsonKey(name: 'short_name') String? shortName,
@JsonKey(name: 'logo_url') String? logoUrl,
String? bic,
}) = _SbpBankModel;
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
_$SbpBankModelFromJson(json);
}

View File

@@ -1,42 +1,117 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final ConsultantRepository consultantRepository;
final Duration scoringPollInterval;
bool _update = false;
String? _userSlug;
Timer? _scoringPollTimer;
RegisterConsultantCubit({required this.consultantRepository})
: super(
const RegisterConsultantState(
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
loading: true,
acceptOffer: false,
isUpdating: false,
),
);
static const String individualPersonLegalType = 'individualPerson';
RegisterConsultantCubit({
required this.consultantRepository,
this.scoringPollInterval = const Duration(seconds: 3),
}) : super(
const RegisterConsultantState(
consultant: ConsultantProfileModel(
socialLinks: SocialLinksModel(),
hasMedicalEducation: true,
legalType: individualPersonLegalType,
),
loading: true,
acceptOffer: false,
acceptFrdoAgreement: false,
isUpdating: false,
),
);
String? get userSlug => _userSlug;
void setUserSlug(String? slug) {
if (slug != null && slug.isNotEmpty) {
_userSlug = slug;
}
}
Future<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);
_userSlug = slug;
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;
List<CertificatesModel>? certificates = myRequestStatus
.educationModel
.completedCourses
?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(title: course.title, date: course.date);
}).toList();
return CertificatesModel(title: course.title, date: course.date);
})
.toList();
String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null;
}
final legal = myRequestStatus.legalEntity;
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
emit(
RegisterConsultantState(
consultant: ConsultantProfileModel(
@@ -46,33 +121,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
personalSiteLink: sanitizeLink(
myRequestStatus.educationModel.personalSiteLink,
),
),
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
hasMedicalEducation:
myRequestStatus.educationModel.hasMedicalEducation == 1,
legalType: legal?.type ?? individualPersonLegalType,
lastName: _emptyToNull(legal?.lastName),
firstName: _emptyToNull(legal?.firstName),
middleName: _emptyToNull(legal?.middleName),
inn: _emptyToNull(legal?.inn),
snils: _emptyToNull(legal?.snils),
passportSeries: _emptyToNull(legal?.passportSeries),
passportNumber: _emptyToNull(legal?.passportNumber),
passportIssueDate: legal?.normalizedPassportIssueDate,
passportIssuer: _emptyToNull(legal?.passportIssuer),
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
birthDate: legal?.normalizedBirthDate,
birthPlace: _emptyToNull(legal?.birthPlace),
registrationAddress: _emptyToNull(legal?.registrationAddress),
residenceAddress: _emptyToNull(legal?.residenceAddress),
citizenship: _emptyToNull(legal?.citizenship),
taxResidentUs: legal?.taxResidentUs ?? false,
foreignTaxResident: legal?.foreignTaxResident ?? false,
sbpPhone: _emptyToNull(legal?.sbpPhone),
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
),
acceptOffer: false,
acceptFrdoAgreement: false,
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
sbpBanks: sbpBanks,
isUpdating: _update,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
fieldErrors: const <String, String>{},
error: null,
),
);
if (scoringStatus.isProcessing) {
startScoringPoll();
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
}
}
String? _emptyToNull(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
return value.trim();
}
Future<void> disableLoading({String? slug}) async {
try {
setUserSlug(slug);
final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks();
emit(
state.copyWith(
loading: false,
certPhotos: certPhotos,
sbpBanks: sbpBanks,
error: null,
fieldErrors: const <String, String>{},
awaitingScoringOutcome: false,
consultant: state.consultant.copyWith(
legalType: individualPersonLegalType,
citizenship: state.consultant.citizenship,
taxResidentUs: state.consultant.taxResidentUs ?? false,
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
),
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(loading: false, error: e.toString()));
}
}
Future<void> disableLoading() async {
try {
final certPhotos = await consultantRepository.getConsultantCertPhotos();
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(loading: false, error: e.toString()));
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
}
}
@@ -82,41 +231,325 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
if (slug != null) {
await getMyRequestStatus(slug);
} else {
await disableLoading();
await disableLoading(slug: _userSlug);
}
}
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
void updateConsultantProfileModel(
ConsultantProfileModel consultant, [
bool? acceptOffer,
bool? acceptFrdoAgreement,
]) {
emit(
state.copyWith(
consultant: consultant,
acceptOffer: acceptOffer ?? state.acceptOffer,
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
loading: false,
error: null,
fieldErrors: const <String, String>{},
),
);
}
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));
if (!state.consultant.hasValidPassportData) {
emit(state.copyWith(error: 'Заполните паспортные данные'));
return;
}
// Show scoring widget immediately while request / job are in progress.
emit(
state.copyWith(
error: null,
fieldErrors: const <String, String>{},
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
awaitingScoringOutcome: true,
scoringRetryInProgress: false,
),
);
try {
if (state.newAddedPhotos.isNotEmpty) {
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
await consultantRepository.storeConsultantCertPhotos(
files: state.newAddedPhotos,
);
}
if (state.deletedPhotoIds.isNotEmpty) {
for (final int id in state.deletedPhotoIds) {
await consultantRepository.deletePhoto(id);
}
}
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
await consultantRepository.registerConsultant(
consultant: buildSubmitPayload(),
update: _update,
);
_update = true;
emit(
state.copyWith(
isUpdating: true,
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
scoringRetryInProgress: false,
awaitingScoringOutcome: true,
newAddedPhotos: const <File>[],
deletedPhotoIds: const <int>[],
fieldErrors: const <String, String>{},
error: null,
),
);
startScoringPoll();
} on DioException catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
isScoringPolling: false,
alfaScoringStatus: AlfaScoringStatus.none,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(error: e.toString()));
emit(
state.copyWith(
error: e.toString(),
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
isScoringPolling: false,
alfaScoringStatus: AlfaScoringStatus.none,
),
);
}
}
Future<void> retryScoring() async {
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
return;
}
emit(
state.copyWith(
scoringRetryInProgress: true,
error: null,
fieldErrors: const <String, String>{},
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
awaitingScoringOutcome: true,
),
);
_update = true;
try {
if (!state.consultant.hasValidPassportData) {
emit(
state.copyWith(
scoringRetryInProgress: false,
isScoringPolling: false,
awaitingScoringOutcome: false,
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
error: 'Заполните паспортные данные',
),
);
return;
}
await consultantRepository.registerConsultant(
consultant: buildSubmitPayload(),
update: true,
);
startScoringPoll();
emit(
state.copyWith(
scoringRetryInProgress: false,
isUpdating: true,
awaitingScoringOutcome: true,
),
);
} on DioException catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
scoringRetryInProgress: false,
isScoringPolling: false,
awaitingScoringOutcome: false,
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(
state.copyWith(
scoringRetryInProgress: false,
isScoringPolling: false,
awaitingScoringOutcome: false,
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
error: e.toString(),
),
);
}
}
void startScoringPoll() {
final slug = _userSlug;
if (slug == null || slug.isEmpty) {
emit(
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
);
return;
}
_stopScoringPoll();
emit(state.copyWith(isScoringPolling: true));
_scoringPollTimer = Timer.periodic(
scoringPollInterval,
(_) => _pollScoringStatus(slug),
);
unawaited(_pollScoringStatus(slug));
}
Future<void> _pollScoringStatus(String slug) async {
if (isClosed) {
return;
}
try {
final status = await consultantRepository.getMyRequestStatus(slug);
if (isClosed) {
return;
}
final scoringStatus =
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
// API stays on `none` until ProcessConsultantScoringJob starts
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
// UI and continue polling until a real terminal/processing status arrives.
if (scoringStatus == AlfaScoringStatus.none) {
emit(
state.copyWith(
requestModel: status,
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
),
);
return;
}
final isTerminal = scoringStatus.isTerminal;
emit(
state.copyWith(
requestModel: status,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
// Clear session flag on terminal so success sheet can fire once
// (listenWhen: previous.awaiting && !current.awaiting && complete).
awaitingScoringOutcome: isTerminal
? false
: state.awaitingScoringOutcome,
),
);
if (!scoringStatus.isProcessing) {
_stopScoringPoll();
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
// Keep polling on transient errors while scoring is in progress.
}
}
void _stopScoringPoll() {
_scoringPollTimer?.cancel();
_scoringPollTimer = null;
}
Future<void> addCertPhotos(List<File> files) async {
if (files.isEmpty) {
return;
}
try {
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
final compressedFiles = await ImageCompressUtility.compressFilesList(
files,
);
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
emit(
state.copyWith(
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
error: null,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
@@ -130,7 +563,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
return;
}
if (path != null) {
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
emit(
state.copyWith(
newAddedPhotos: state.newAddedPhotos
.where((file) => file.uri.path != path)
.toList(),
),
);
}
}
@override
Future<void> close() {
_stopScoringPoll();
return super.close();
}
}

View File

@@ -1,8 +1,10 @@
import 'dart:io';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'register_consultant_state.freezed.dart';
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
const factory RegisterConsultantState({
required ConsultantProfileModel consultant,
required bool acceptOffer,
@Default(false) bool acceptFrdoAgreement,
required bool loading,
required bool isUpdating,
ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
@Default(<ConsultantCertPhotoModel>[])
List<ConsultantCertPhotoModel> certPhotos,
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
@Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds,
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
@Default(false) bool isScoringPolling,
@Default(false) bool scoringRetryInProgress,
/// True only after successful register/retry in this session until scoring is terminal.
@Default(false) bool awaitingScoringOutcome,
@Default(<String, String>{}) Map<String, String> fieldErrors,
String? error,
}) = _RegisterConsultantState;
}

View File

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

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class BecomeConsultantScreen extends StatefulWidget {
const BecomeConsultantScreen({super.key});
final String? slug;
const BecomeConsultantScreen({super.key, this.slug});
@override
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
title: const Text('Стать консультантом'),
automaticallyImplyLeading: false,
centerTitle: false,
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
leading: IconButton(
onPressed: () => GoRouter.of(context).pop(),
icon: const Icon(Icons.close),
),
),
body: const Padding(
padding: EdgeInsets.all(16),
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
InkWell _startRegistrationButton() {
return InkWell(
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
onTap: () => GoRouter.of(context).pushReplacementNamed(
'stepperBecomeConsultantScreen',
extra: {'slug': widget.slug, 'isNewRegistration': true},
),
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: const Center(
child: Text(
'Начать регистрацию',
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
style: TextStyle(
color: Colors.white,
height: 1,
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
),
),

View File

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

View File

@@ -1,8 +1,11 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
}
class _LastStepScreenState extends State<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(
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
child: Text(
'Последний шаг',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
if (state.awaitingScoringOutcome ||
state.isScoringPolling ||
state.alfaScoringStatus !=
AlfaScoringStatus.none) ...[
const SizedBox(height: 16),
const ScoringStatusWidget(),
],
if (state.requestModel?.canceledModeration != null) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0x33FFCC00),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.requestModel!.canceledModeration!.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
if (state.requestModel!.canceledModeration!.additional !=
null) ...[
const SizedBox(height: 8),
for (final item
in state
.requestModel!
.canceledModeration!
.additional!) ...[
Text(
'• $item',
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
],
],
),
),
),
],
const SizedBox(height: 16),
CheckboxListTile(
value: checkboxState,
value: state.acceptOffer,
title: Text(
hidePlans
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
: 'Я ознакомлен и принимаю договор публичной офёрты',
style: const TextStyle(fontSize: 18, height: 1),
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
style: const TextStyle(fontSize: 18, height: 1.2),
),
onChanged: (_) {
setState(() {
checkboxState = !checkboxState;
});
context.read<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,
),
],
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton(
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<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 ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
hidePlans
? 'Политика конфиденциальности и пользовательское соглашение'
: 'Договор оферты',
style: const TextStyle(
height: 1,
fontSize: 16,
color: Color(0xFF323F4B),
decoration: TextDecoration.underline,
),
),
),
),
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
return Container(
height: 0.4,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
);
}
}

View File

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

View File

@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
const Text(
'Загрузите фото или сканы дипломов и сертификатов',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) {
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
final photos = state.certPhotos
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
.toList();
final addedPhotos = state.newAddedPhotos;
final totalPhotosCount = photos.length + addedPhotos.length;
return Column(
children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
if (isUploading) ...[
const LinearProgressIndicator(),
const SizedBox(height: 16),
],
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 3 / 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 3 / 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) {
if (index == totalPhotosCount) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(),
child: Ink(
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: Icon(Icons.add, color: appTheme.blueA400),
),
);
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
return _LocalCertPhotoCard(
photo: localPhoto,
allPhotos: addedPhotos,
onRemove: () => _removePhoto(path: localPhoto.uri.path),
onRemove: () =>
_removePhoto(path: localPhoto.uri.path),
);
}
}
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
});
try {
// ignore: use_build_context_synchronously
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
await context.read<RegisterConsultantCubit>().addCertPhotos(
newImages.map((file) => File(file.path)).toList(),
);
} finally {
if (mounted) {
setState(() {
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
final List<ConsultantCertPhotoModel> allPhotos;
final VoidCallback onRemove;
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
const _CertPhotoCard({
required this.photo,
required this.allPhotos,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
PhotoViewScreen(
imageUrls: allPhotos.map((e) => e.url).toList(),
isLocal: false,
index: allPhotos.indexOf(photo),
),
);
},
borderRadius: BorderRadius.circular(20),
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
Ink(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(photo.url),
),
),
),
Positioned(
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(),
child: Ink(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
child: const Icon(size: 20, Icons.close, color: Colors.black),
decoration: const BoxDecoration(
color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
),
),
),
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
final List<File> allPhotos;
final VoidCallback onRemove;
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
const _LocalCertPhotoCard({
required this.photo,
required this.allPhotos,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(),
child: Ink(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
child: const Icon(size: 20, Icons.close, color: Colors.black),
decoration: const BoxDecoration(
color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
),
),
),

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,26 @@ 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) {
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
child: Text(
'Регистрация в качестве',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
const SizedBox(height: 16),
Column(
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType,
activeColor: appTheme.blueA400,
// ignore: deprecated_member_use
onChanged: (value) {
setState(() {
registrationAsType = value!;
});
},
onChanged: (_) {},
),
const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
child: Text(
'Физического лица',
style: TextStyle(fontSize: 18, height: 1),
),
),
),
],
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<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 +110,19 @@ 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,26 +135,34 @@ 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,
),
),
),
),
],
),
],
),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
child: Text(
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
),
),
],
),
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
return Container(
height: 0.4,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
);
}
}

View File

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

View File

@@ -1,11 +1,13 @@
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
@@ -21,39 +23,145 @@ import 'package:lottie/lottie.dart';
class StepperBecomeConsultantScreen extends StatefulWidget {
final String? slug;
final bool? isCanceled;
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
final bool isNewRegistration;
const StepperBecomeConsultantScreen({
super.key,
this.slug,
this.isCanceled,
this.isNewRegistration = false,
});
@override
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
State<StepperBecomeConsultantScreen> createState() =>
_StepperBecomeConsultantScreenState();
}
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
double progressValue = 1 / 6;
class _StepperBecomeConsultantScreenState
extends State<StepperBecomeConsultantScreen> {
double progressValue = 0;
int step = 1;
final pageController = PageController();
late final RegisterConsultantCubit registrationConsultantCubit;
bool isLoading = false;
bool _successSheetShown = false;
@override
void initState() {
super.initState();
if (widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
if (widget.isNewRegistration || widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()
..disableLoading(slug: widget.slug);
} else {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
registrationConsultantCubit = sl<RegisterConsultantCubit>()
..getMyRequestStatus(widget.slug!);
}
}
static const int totalSteps = 7;
List<Widget> _pages() {
return const [
CertificatesAndDiplomasScreen(),
PhotoOrScansScreen(),
AdditionalSettingsScreen(),
SocialNetworksScreen(),
RegistrationAsScreen(),
PassportDataScreen(),
LastStepScreen(),
];
}
Future<void> _showSuccessSheet(BuildContext context) async {
if (_successSheetShown) {
return;
}
_successSheetShown = true;
await showModalBottomSheet(
useRootNavigator: true,
showDragHandle: true,
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
height: 1,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Lottie.asset(
'assets/lottie_animations/success.json',
width: 160,
height: 160,
),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 1,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
Navigator.of(context).pop();
GoRouter.of(context).pop();
},
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider<RegisterConsultantCubit>(
create: (context) => registrationConsultantCubit,
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
listenWhen: (previous, current) =>
previous.awaitingScoringOutcome &&
!current.awaitingScoringOutcome &&
current.alfaScoringStatus.isComplete,
listener: (context, state) {
_showSuccessSheet(context);
},
builder: (context, state) {
if (state.loading) {
return const Scaffold(body: Loader());
}
if (state.error != null) {
if (state.error != null &&
state.requestModel == null &&
widget.slug != null &&
state.alfaScoringStatus == AlfaScoringStatus.none) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
@@ -72,248 +180,320 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
),
),
body: DataErrorWidget(
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
onReload: () => context
.read<RegisterConsultantCubit>()
.reloadData(widget.slug),
),
);
}
if (progressValue == 0) {
progressValue = 1 / totalSteps;
}
final submitDisabled =
isLoading ||
state.isScoringPolling ||
state.scoringRetryInProgress;
return Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row(
children: [
Container(
height: 48,
width: MediaQuery.of(context).size.width * 0.2,
padding: const EdgeInsets.only(left: 16, right: 4),
child: FloatingActionButton.extended(
heroTag: 'backButtonRegistration',
highlightElevation: 0,
focusElevation: 0,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async {
setState(() {
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
},
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
floatingActionButtonLocation:
FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row(
children: [
Container(
height: 48,
width: MediaQuery.of(context).size.width * 0.2,
padding: const EdgeInsets.only(left: 16, right: 4),
child: FloatingActionButton.extended(
heroTag: 'backButtonRegistration',
highlightElevation: 0,
focusElevation: 0,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async {
setState(() {
pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
});
},
label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
),
if (widget.isCanceled == true)
InkWell(
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(state.requestModel!.canceledModeration!.title),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
),
if (widget.isCanceled == true && step != totalSteps)
InkWell(
onTap: () {
final canceledModeration =
state.requestModel?.canceledModeration;
if (canceledModeration == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(canceledModeration.title),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (canceledModeration.additional != null)
for (final item
in canceledModeration.additional!) ...[
Text('• $item'),
],
],
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
],
),
);
},
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 48,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
child: const Center(
child: Text(
'Комментарий',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
},
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 48,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
child: const Center(
child: Text(
'Комментарий',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
),
),
),
Container(
height: 48,
width: widget.isCanceled == true
? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16),
child: FloatingActionButton.extended(
heroTag: 'nextButtonRegistration',
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: isLoading
? Colors.grey
: widget.isCanceled == true
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
onPressed: isLoading
? null
: () async {
if (step == 6) {
if (widget.isCanceled == true) {
return;
}
if (registrationConsultantCubit.state.acceptOffer) {
setState(() {
isLoading = true;
});
try {
await registrationConsultantCubit.registerConsultant();
if (!context.mounted) return;
if (registrationConsultantCubit.state.error != null) {
return;
}
await showModalBottomSheet(
useRootNavigator: true,
showDragHandle: true,
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
Navigator.of(context).pop();
GoRouter.of(context).pop();
},
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
),
),
),
],
),
),
Container(
height: 48,
width: widget.isCanceled == true && step != totalSteps
? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16),
child: FloatingActionButton.extended(
heroTag: 'nextButtonRegistration',
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: submitDisabled
? Colors.grey
: widget.isCanceled == true && step != totalSteps
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
onPressed: submitDisabled
? null
: () async {
if (step == totalSteps) {
if (!state.consultant.hasValidPassportData) {
showDialog(
context: context,
builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(
horizontal: 4,
),
content: const Text(
'Заполните паспортные данные',
),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () =>
Navigator.of(context).pop(),
),
);
} catch (e) {
if (!context.mounted) return;
showDialog<void>(
],
),
);
return;
}
if (registrationConsultantCubit
.hasAcceptedRequiredAgreements) {
setState(() {
isLoading = true;
});
try {
await registrationConsultantCubit
.registerConsultant();
if (!context.mounted) return;
if (registrationConsultantCubit.state.error !=
null) {
showDialog(
context: context,
builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
content: Text(e.toString()),
actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(
registrationConsultantCubit
.state
.error!,
),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () => Navigator.of(context).pop(),
onPressed: () =>
Navigator.of(context).pop(),
),
],
),
);
} finally {
setState(() {
isLoading = false;
});
return;
}
} else {
showDialog(
// Success sheet is shown by BlocListener when scoring completes.
} catch (e) {
if (!context.mounted) return;
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
content: const Text('Примите договор публичной офёрты'),
actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(e.toString()),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () {
Navigator.of(context).pop();
},
onPressed: () =>
Navigator.of(context).pop(),
),
],
),
);
} finally {
setState(() {
isLoading = false;
});
}
} else {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
showDialog(
context: context,
builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(
state.isUpdating
? 'Примите договор публичной оферты'
: 'Примите оферту и согласие ФРДО',
),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
);
}
},
label: isLoading
? const CircularProgressIndicator()
: widget.isCanceled == true
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
} else {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
});
}
},
label: submitDisabled && step == totalSteps
? const CircularProgressIndicator()
: widget.isCanceled == true && step != totalSteps
? Icon(
Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text(
step == totalSteps ? 'Отправить' : 'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(
color: Colors.white,
),
),
),
],
),
appBar: AppBar(
automaticallyImplyLeading: false,
leading: Builder(
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
),
centerTitle: true,
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
bottom: PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, 32),
child: Padding(
padding: const EdgeInsets.all(16),
child: TweenAnimationBuilder<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

@@ -0,0 +1,28 @@
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:flutter/material.dart';
/// Wraps a form field with [ValidationErrorWidget] when [error] is present.
/// Pass [hasError] into [CustomTextFormField] from the parent.
class PassportFieldWithError extends StatelessWidget {
const PassportFieldWithError({required this.child, this.error, super.key});
final Widget child;
final String? error;
@override
Widget build(BuildContext context) {
final message = error?.trim();
final showError = message != null && message.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
if (showError) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: message),
],
],
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
class PassportSectionTitle extends StatelessWidget {
const PassportSectionTitle({required this.title, super.key});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: Text(
title,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
);
}
}
class PassportScreenTitle extends StatelessWidget {
const PassportScreenTitle({required this.title, super.key});
final String title;
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyles.textFS24FW700.copyWith(color: appTheme.black),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ScoringStatusWidget extends StatelessWidget {
const ScoringStatusWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
buildWhen: (previous, current) =>
previous.alfaScoringStatus != current.alfaScoringStatus ||
previous.isScoringPolling != current.isScoringPolling ||
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
builder: (context, state) {
final isWaiting =
state.awaitingScoringOutcome || state.isScoringPolling;
final status = state.alfaScoringStatus;
final showProcessing = status.isProcessing ||
(isWaiting && !status.isTerminal);
if (status == AlfaScoringStatus.none && !isWaiting) {
return const SizedBox.shrink();
}
final displayStatus = showProcessing && !status.isProcessing
? AlfaScoringStatus.beneficiaryCreating
: status;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: appTheme.greyD9),
color: appTheme.whiteA700,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScoringStatusHeader(status: displayStatus),
const SizedBox(height: 12),
if (showProcessing) const ScoringProcessingBody(),
if (status.isComplete) const ScoringCompleteBody(),
if (status.isFailed)
ScoringFailedBody(
retryInProgress: state.scoringRetryInProgress,
onRetry: () => context
.read<RegisterConsultantCubit>()
.retryScoring(),
),
],
),
),
),
);
},
);
}
}
class ScoringStatusHeader extends StatelessWidget {
const ScoringStatusHeader({required this.status, super.key});
final AlfaScoringStatus status;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'Проверка данных',
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
),
),
Text(
ScoringStatusLabels.hintFor(status),
style: TextStyles.textSMedium.copyWith(
color: ScoringStatusLabels.colorFor(status),
),
),
],
);
}
}
class ScoringProcessingBody extends StatelessWidget {
const ScoringProcessingBody({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: appTheme.blueA400,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Идёт проверка…',
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
],
);
}
}
class ScoringCompleteBody extends StatelessWidget {
const ScoringCompleteBody({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(Icons.check_circle, color: appTheme.green, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text(
'Проверка успешно завершена',
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
],
);
}
}
class ScoringFailedBody extends StatelessWidget {
const ScoringFailedBody({
required this.retryInProgress,
required this.onRetry,
super.key,
});
final bool retryInProgress;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton(
onPressed: retryInProgress ? null : onRetry,
style: ElevatedButton.styleFrom(
backgroundColor: appTheme.blueA400,
disabledBackgroundColor: appTheme.greyD9,
foregroundColor: appTheme.whiteA700,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: retryInProgress
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: appTheme.whiteA700,
),
)
: Text(
'Повторить',
style: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
),
),
),
],
);
}
}
abstract final class ScoringStatusLabels {
static String hintFor(AlfaScoringStatus status) {
return switch (status) {
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
AlfaScoringStatus.scoringPending => 'Ожидание результата',
AlfaScoringStatus.scoringComplete => 'Успешно',
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
AlfaScoringStatus.scoringFailed => 'Ошибка',
AlfaScoringStatus.none => '',
};
}
static Color colorFor(AlfaScoringStatus status) {
return switch (status) {
AlfaScoringStatus.scoringComplete => appTheme.green,
AlfaScoringStatus.beneficiaryFailed ||
AlfaScoringStatus.scoringFailed => appTheme.red,
AlfaScoringStatus.beneficiaryCreating ||
AlfaScoringStatus.scoringPending => appTheme.blueA400,
AlfaScoringStatus.none => appTheme.grey7B,
};
}
}

View File

@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:http_parser/http_parser.dart';
@@ -11,36 +12,53 @@ class ConsultantRepository {
const ConsultantRepository({required this.consultantService});
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
Future<void> registerConsultant({
required ConsultantProfileModel consultant,
bool update = false,
}) async {
if (update) {
return await consultantService.consultantUpdate(consultantModel: consultant);
return await consultantService.consultantUpdate(
consultantModel: consultant,
);
} else {
return await consultantService.consultantRegistration(consultantModel: consultant);
return await consultantService.consultantRegistration(
consultantModel: consultant,
);
}
}
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
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 {
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
required List<File> files,
}) async {
final multipartFiles = <MultipartFile>[];
for (final file in files) {
final fileBytes = await file.readAsBytes();
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
final multipartFile = MultipartFile.fromBytes(
fileBytes,
filename: file.path.split('/').last,
contentType: MediaType('application', 'octet-stream'),
);
multipartFiles.add(multipartFile);
}
@@ -48,22 +66,24 @@ class ConsultantRepository {
return [];
}
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
return await consultantService.storeConsultantCertPhotos(
photos: multipartFiles,
);
}
Future<void> hideConsultantCertPhoto(int id) async {
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
await consultantService.hideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
}
Future<void> unHideConsultantCertPhoto(int id) async {
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
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,21 +11,39 @@ part 'consultant_service.g.dart';
abstract class ConsultantService {
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
@POST('/api/consultant_request/registration')
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
@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});
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});
Future<ConsultationRequestModel> getConsultantRequestStatus({
@Path() required String slug,
});
@GET('/api/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
/// Свой статус заявки для редактирования/модерации.
/// С паспортными полями в `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')
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
@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();
@@ -32,14 +51,19 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/store')
@MultiPart()
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
@Part(name: 'photos[]', contentType: 'multipart/form-data')
required List<MultipartFile> photos,
});
@POST('/api/v2/consultant_request/cert_photos/hide')
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
Future<void> hideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
Future<void> unHideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id});

View File

@@ -0,0 +1,167 @@
import 'package:dio/dio.dart';
/// Parsed Laravel 422 validation payload for consultant registration UI.
class LaravelValidationErrors {
const LaravelValidationErrors({
required this.summary,
this.fieldErrors = const <String, String>{},
});
final String summary;
final Map<String, String> fieldErrors;
static const LaravelValidationErrors empty = LaravelValidationErrors(
summary: '',
);
bool get hasFieldErrors => fieldErrors.isNotEmpty;
}
const _genericValidationSummary = 'Проверьте введённые данные';
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз';
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
/// Raw backend messages are never shown in the UI.
LaravelValidationErrors parseLaravelValidationErrors(Object error) {
if (error is! DioException) {
return const LaravelValidationErrors(summary: _genericNetworkSummary);
}
final data = error.response?.data;
if (error.response?.statusCode == 422 && data is Map) {
final fieldErrors = <String, String>{};
final errors = data['errors'];
if (errors is Map) {
errors.forEach((key, value) {
final fieldKey = key.toString();
final raw = _rawErrorText(value);
fieldErrors[fieldKey] = mapConsultantValidationMessage(
fieldKey: fieldKey,
rawMessage: raw,
);
});
}
if (fieldErrors.isNotEmpty) {
final uniqueMessages = fieldErrors.values.toSet().toList();
return LaravelValidationErrors(
summary: uniqueMessages.join('\n'),
fieldErrors: fieldErrors,
);
}
return const LaravelValidationErrors(summary: _genericValidationSummary);
}
return const LaravelValidationErrors(summary: _genericNetworkSummary);
}
String _rawErrorText(Object? value) {
if (value is List && value.isNotEmpty) {
return value.map((e) => e.toString()).join(' ');
}
if (value is String) {
return value;
}
return '';
}
/// Maps a backend validation message for [fieldKey] to a short Russian UI string.
String mapConsultantValidationMessage({
required String fieldKey,
required String rawMessage,
}) {
final field = fieldKey.split('.').last.toLowerCase();
final raw = rawMessage.toLowerCase();
final isUnique =
raw.contains('taken') ||
raw.contains('unique') ||
raw.contains('already') ||
raw.contains('уже существ') ||
raw.contains('уже занят');
final isRequired =
raw.contains('required') ||
raw.contains('обязательн') ||
raw.contains('required_if');
final isAccepted =
raw.contains('accepted') || field.contains('agreement');
final isUrl = raw.contains('url') || field.contains('link');
final isFormat =
raw.contains('format') ||
raw.contains('invalid') ||
raw.contains('regex') ||
raw.contains('digits') ||
raw.contains('size') ||
raw.contains('date') ||
raw.contains('before');
if (isAccepted) {
if (field.contains('frdo')) {
return 'Подтвердите согласие ФРДО';
}
return 'Примите условия оферты';
}
if (isUnique) {
return switch (field) {
'inn' => 'Такой ИНН существует',
'snils' => 'Такой СНИЛС существует',
'passport_number' || 'passport_series' => 'Такой паспорт существует',
'sbp_phone' => 'Такой телефон СБП существует',
_ => 'Такие данные уже существуют',
};
}
if (isRequired) {
return switch (field) {
'inn' => 'Укажите ИНН',
'snils' => 'Укажите СНИЛС',
'last_name' => 'Укажите фамилию',
'first_name' => 'Укажите имя',
'middle_name' => 'Укажите отчество',
'passport_series' => 'Укажите серию паспорта',
'passport_number' => 'Укажите номер паспорта',
'passport_issue_date' => 'Укажите дату выдачи паспорта',
'passport_issuer' => 'Укажите, кем выдан паспорт',
'passport_issuer_code' => 'Укажите код подразделения',
'birth_date' => 'Укажите дату рождения',
'birth_place' => 'Укажите место рождения',
'registration_address' => 'Укажите адрес регистрации',
'residence_address' => 'Укажите адрес проживания',
'citizenship' => 'Укажите гражданство',
'sbp_phone' => 'Укажите телефон СБП',
'sbp_bank_member_id' => 'Выберите банк СБП',
'completed_courses' => 'Добавьте хотя бы один курс',
'title' => 'Укажите название курса',
'date' => 'Укажите дату курса',
'agreement_accepted' => 'Примите условия оферты',
'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО',
_ => 'Заполните поле',
};
}
if (isUrl) {
return 'Некорректная ссылка';
}
if (isFormat || raw.isNotEmpty) {
return switch (field) {
'inn' => 'Некорректный ИНН',
'snils' => 'Некорректный СНИЛС',
'passport_series' => 'Некорректная серия паспорта',
'passport_number' => 'Некорректный номер паспорта',
'passport_issue_date' => 'Некорректная дата выдачи',
'passport_issuer_code' => 'Некорректный код подразделения',
'birth_date' => 'Некорректная дата рождения',
'citizenship' => 'Некорректный код гражданства',
'sbp_phone' => 'Некорректный телефон СБП',
'title' => 'Слишком короткое название курса',
'date' => 'Некорректная дата курса',
_ => 'Некорректное значение',
};
}
return 'Проверьте поле';
}

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'feed_blog_post_model.freezed.dart';
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
@JsonKey(name: "total_views") required int totalViews,
@JsonKey(name: "total_comments") required int totalComments,
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
@Default([]) List<FeedGalleryImage> images,
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
}) = _FeedBlogPostModel;
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);

View File

@@ -0,0 +1,41 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
class PostFeedMapper {
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
return FeedBlogPostModel(
id: item.postId,
state: item.postState,
image: resolveMediaUrl(item.postImageUrl),
description: item.postTextMd ?? item.postShortDescription ?? '',
author: Author(
authorSlug: item.postAuthorDetails?.slug ?? '',
authorName: item.postAuthorName,
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
),
videoUrl: '',
createdAt: item.postCreatedAt,
updatedAt: item.updatedAt,
title: item.postTitle,
totalLikes: item.postLikesCount,
totalViews: item.postViewsCount,
totalComments: item.postCommentsCount,
wasLikedByMe: item.postIsLikedByUser,
images: item.images,
videoEmbedUrl: item.videoEmbedUrl,
videoUrlThumb: item.videoUrlThumb,
);
}
static String resolveMediaUrl(String? url) {
if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
return '${sl<AppConfig>().baseUrl}/storage/$url';
}
}

View File

@@ -0,0 +1,95 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'post_feed_pagination_model.freezed.dart';
part 'post_feed_pagination_model.g.dart';
@freezed
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
const factory PostFeedPaginationModel({
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
required CursorLinks links,
required CursorMeta meta,
}) = _PostFeedPaginationModel;
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
}
@freezed
abstract class PostFeedItemModel with _$PostFeedItemModel {
const factory PostFeedItemModel({
required int id,
@JsonKey(name: 'post_id') required int postId,
@JsonKey(name: 'post_author_id') required int postAuthorId,
@JsonKey(name: 'post_author_name') required String postAuthorName,
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
@JsonKey(name: 'post_title') required String postTitle,
@JsonKey(name: 'post_short_description') String? postShortDescription,
@JsonKey(name: 'post_text_md') String? postTextMd,
@JsonKey(name: 'post_image_url') String? postImageUrl,
@Default([]) List<FeedGalleryImage> images,
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
@JsonKey(name: 'post_views_count') required int postViewsCount,
@JsonKey(name: 'post_likes_count') required int postLikesCount,
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
@JsonKey(name: 'post_url') String? postUrl,
@JsonKey(name: 'post_state') required String postState,
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
}) = _PostFeedItemModel;
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
}
@freezed
abstract class FeedGalleryImage with _$FeedGalleryImage {
const factory FeedGalleryImage({
required String url,
@Default('horizontal') String orientation,
int? id,
int? position,
}) = _FeedGalleryImage;
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
}
@freezed
abstract class PostAuthorDetails with _$PostAuthorDetails {
const factory PostAuthorDetails({
required String slug,
@JsonKey(name: 'is_consultant') required bool isConsultant,
}) = _PostAuthorDetails;
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
}
@freezed
abstract class CursorLinks with _$CursorLinks {
const factory CursorLinks({
required String? first,
required String? last,
required String? prev,
required String? next,
}) = _CursorLinks;
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
}
@freezed
abstract class CursorMeta with _$CursorMeta {
const factory CursorMeta({
required String path,
@JsonKey(name: 'per_page') required int perPage,
@JsonKey(name: 'next_cursor') String? nextCursor,
@JsonKey(name: 'prev_cursor') String? prevCursor,
}) = _CursorMeta;
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
}
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();

View File

@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class FeedCubit extends Cubit<FeedState> {
final FeedRepository feedRepository;
int currentPage = 1;
late int lastPage;
String? _nextCursor;
bool _hasMore = true;
bool isLoading = false;
String? _query;
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
}
}
Future<void> loadFeedBlogPostList({String? query}) async {
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
try {
_query = query;
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
lastPage = feedBlogPosts.meta.lastPage;
_nextCursor = null;
_hasMore = true;
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed) {
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
}
} catch (e, st) {
await reportFrom(e, st, this);
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
}
}
Future<void> loadNextPage() async {
if (currentPage < lastPage && !isLoading && !isClosed) {
isLoading = true;
Future<void> loadNextPage({int? currentUserId}) async {
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
return;
}
if (!isClosed) {
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
}
isLoading = true;
emit((state as FeedStateLoaded).copyWith(isLoading: true));
try {
if (currentPage < lastPage && !isClosed) {
currentPage++;
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
isLoading = false;
if (!isClosed) {
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
}
}
} catch (e, st) {
await reportFrom(e, st, this);
if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
}
try {
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed && state is FeedStateLoaded) {
emit(
FeedState.loaded(
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
isLoading: false,
),
);
}
} catch (e, st) {
await reportFrom(e, st, this);
if (!isClosed && state is FeedStateLoaded) {
emit((state as FeedStateLoaded).copyWith(isLoading: false));
} else if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
}
} finally {
isLoading = false;
}
}

View File

@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
final state = feedCubit.state;
// Загружаем данные, если они еще не загружены
if (state is! FeedStateLoaded) {
feedCubit.loadFeedBlogPostList();
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
}
});
super.initState();
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
switch (_selectedChipIndex) {
case 0:
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
context.read<FeedCubit>().loadFeedBlogPostList(
query: query,
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
);
break;
case 1 when !hidePlans:
context.read<PlansCubit>().loadPlans(query: query);
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
switch (_selectedChipIndex) {
case 0:
context.read<FeedCubit>().loadFeedBlogPostList();
context.read<FeedCubit>().loadFeedBlogPostList(
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
);
break;
case 1 when !hidePlans:
context.read<PlansCubit>().loadPlans();

View File

@@ -0,0 +1,256 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
import 'package:flutter/material.dart';
/// Gallery + optional embed video for feed card and blog post detail.
class BlogPostMediaContent extends StatefulWidget {
const BlogPostMediaContent({
super.key,
required this.galleryImages,
required this.imageUrl,
required this.videoEmbedUrl,
required this.currentGalleryIndex,
required this.onOpenGallery,
required this.onGalleryChanged,
required this.videoUrlThumb,
});
final List<FeedGalleryImage> galleryImages;
final String imageUrl;
final String? videoEmbedUrl;
final String? videoUrlThumb;
final int currentGalleryIndex;
final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> onGalleryChanged;
@override
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
}
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 1);
/// Landscape / video slide aspect (width / height).
static const _horizontalAspectRatio = 16 / 9;
/// Portrait slide aspect (width / height).
static const _verticalAspectRatio = 3 / 4;
bool _isCounterVisible = false;
Timer? _counterHideTimer;
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
List<FeedGalleryImage> get _gallery {
return [
for (final image in widget.galleryImages)
if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty)
FeedGalleryImage(
url: url,
orientation: image.orientation,
id: image.id,
position: image.position,
),
];
}
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
int get _slideCount {
if (_gallery.isNotEmpty) {
return _gallery.length + (_hasVideo ? 1 : 0);
}
if (_hasVideo) {
return 1;
}
return 0;
}
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length;
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
/// Height is fixed for the whole post from the first gallery image (web-like).
String get _postOrientation {
if (_gallery.isNotEmpty) {
return _gallery.first.orientation;
}
return 'horizontal';
}
double _heightForOrientation(String orientation, double width) {
final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio;
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
return (width / aspectRatio).clamp(180.0, maxHeight);
}
@override
void dispose() {
_counterHideTimer?.cancel();
super.dispose();
}
@override
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
_scheduleCounterHideOnVideo();
}
}
void _scheduleCounterHideOnVideo() {
_counterHideTimer?.cancel();
if (!_isCurrentSlideVideo) return;
_counterHideTimer = Timer(_counterDisplayDuration, () {
if (mounted && _isCurrentSlideVideo) {
setState(() => _isCounterVisible = false);
}
});
}
bool _handleCarouselScroll(ScrollNotification notification) {
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
_counterHideTimer?.cancel();
if (!_isCounterVisible) {
setState(() => _isCounterVisible = true);
}
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
_scheduleCounterHideOnVideo();
}
return false;
}
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
return GestureDetector(
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.sizeOf(context).width,
height: height,
imageUrl: itemUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
);
}
Widget _videoSlide() {
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
}
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) {
return Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: _handleCarouselScroll,
child: CarouselSlider.builder(
itemCount: itemCount,
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
options: CarouselOptions(
viewportFraction: 1,
height: height,
enableInfiniteScroll: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index),
),
),
),
if (itemCount > 1)
Positioned(
top: 12,
right: 12,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _shouldShowCounter ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final gallery = _gallery;
final width = MediaQuery.sizeOf(context).width;
final mediaHeight = _heightForOrientation(_postOrientation, width);
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
final itemCount = _slideCount;
return _carousel(
itemCount: itemCount,
height: mediaHeight,
itemBuilder: (context, itemIndex) {
if (itemIndex < gallery.length) {
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
}
if (_hasVideo) {
return _videoSlide();
}
return const SizedBox.shrink();
},
);
}
if (gallery.length == 1) {
return _imageSlide(gallery.first.url, 0, mediaHeight);
}
if (_hasVideo) {
return SizedBox(
height: mediaHeight,
width: width,
child: _videoSlide(),
);
}
if (widget.imageUrl.isNotEmpty) {
return GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: Hero(
tag: widget.imageUrl,
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
);
}
return const SizedBox.shrink();
}
}
class _GalleryPhotoCounter extends StatelessWidget {
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
final int currentIndex;
final int total;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
const SizedBox(width: 8),
Text(
'${currentIndex + 1} / $total',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
),
],
),
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
@@ -9,8 +8,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -19,9 +20,9 @@ import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
class BlogPostWidget extends StatefulWidget {
final int index;
final FeedBlogPostModel post;
const BlogPostWidget({super.key, required this.index});
const BlogPostWidget({super.key, required this.post});
@override
State<BlogPostWidget> createState() => _BlogPostWidgetState();
@@ -30,243 +31,267 @@ class BlogPostWidget extends StatefulWidget {
class _BlogPostWidgetState extends State<BlogPostWidget> {
late bool isLiked;
late int totalLikes;
bool isMoreOptionsVisible = false;
int currentGalleryIndex = 0;
FeedBlogPostModel get post => widget.post;
@override
void initState() {
super.initState();
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
}
@override
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.post.id != post.id) {
currentGalleryIndex = 0;
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
return;
}
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) {
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
}
}
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
}
Future<void> _toggleLike() async {
final nextLiked = !isLiked;
setState(() {
isLiked = nextLiked;
nextLiked ? totalLikes++ : totalLikes--;
});
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
if (nextLiked) {
await context.read<FeedCubit>().likePost(post.id);
} else {
await context.read<FeedCubit>().unlikedPost(post.id);
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<FeedCubit, FeedState>(
builder: (BuildContext context, FeedState state) {
if (state is FeedStateLoaded) {
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
},
child: CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
? state.feedBlogPostList[widget.index].author.authorProfileIcon
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
),
const SizedBox(width: 8),
// Имя и дата внутри Expanded
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.feedBlogPostList[widget.index].author.authorName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
),
],
),
),
// Кнопка подписки (если нужно)
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
!state.feedBlogPostList[widget.index].author.authorIsMe)
Padding(
padding: const EdgeInsets.only(right: 8),
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
),
// Кнопка меню
CustomPopup(
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
id: state.feedBlogPostList[widget.index].id,
feedBlogPostModel: state.feedBlogPostList[widget.index],
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
),
],
),
),
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
state.feedBlogPostList[widget.index].title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
),
),
],
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
const SizedBox(height: 8),
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Builder(
builder: (context) {
String description = state.feedBlogPostList[widget.index].description;
final isLong = description.length > 200;
if (isLong) {
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
int cutPoint = 200;
// Проверяем, есть ли незакрытые markdown теги
String preview = description.substring(0, cutPoint);
// Считаем открывающие и закрывающие теги
int boldOpen = (preview.split('**').length - 1) ~/ 2;
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
bool hasUnclosedBold = boldOpen > boldClose;
// Если есть незакрытый bold, ищем закрытие
if (hasUnclosedBold) {
int searchEnd = (cutPoint + 100).clamp(0, description.length);
int boldClosePos = description.indexOf('**', cutPoint);
if (boldClosePos != -1 && boldClosePos < searchEnd) {
cutPoint = boldClosePos + 2;
} else {
// Если не нашли, обрезаем до последнего пробела
cutPoint = description.lastIndexOf(' ', 200);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
} else {
// Обрезаем до последнего пробела для красоты
cutPoint = description.lastIndexOf(' ', cutPoint);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
description = description.substring(0, cutPoint);
}
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
return MarkdownHtmlWidget(
markdownText: description,
);
},
),
),
if (state.feedBlogPostList[widget.index].description.length > 200)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
const SizedBox(height: 8),
GestureDetector(
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
);
},
child: Hero(
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
onTap: () async {
setState(() {
isLiked = !isLiked;
isLiked ? totalLikes++ : totalLikes--;
});
// Update feed list immediately by ID (no reload)
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
// Fire network call without introducing UI delay
if (isLiked) {
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
} else {
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
}
},
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
'assets/icons/comment.svg',
height: 20,
width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
text: state.feedBlogPostList[widget.index].totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
height: 15,
width: 12,
'assets/icons/repost.svg',
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
onTap: () {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
state.feedBlogPostList[widget.index].totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
),
),
const SizedBox(height: 8),
],
),
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
_BlogPostHeader(post: post),
_BlogPostTextContent(post: post),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb,
currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery,
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
);
}
return const SizedBox.shrink();
},
const SizedBox(height: 12),
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
const SizedBox(height: 8),
],
),
),
);
}
}
class _BlogPostHeader extends StatelessWidget {
const _BlogPostHeader({required this.post});
final FeedBlogPostModel post;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
child: CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null,
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.author.authorName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
),
],
),
),
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe)
Padding(
padding: const EdgeInsets.only(right: 8),
child: SubscribeButtonWidget(slug: post.author.authorSlug),
),
CustomPopup(
isAuthor: post.author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}',
id: post.id,
feedBlogPostModel: post,
userSlug: post.author.authorSlug,
),
],
),
);
}
}
class _BlogPostTextContent extends StatelessWidget {
const _BlogPostTextContent({required this.post});
final FeedBlogPostModel post;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (post.title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
post.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
),
),
],
if (post.title.isEmpty) const SizedBox(height: 8),
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
if (post.description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
),
if (post.description.length > 200)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
],
);
}
}
class _BlogPostActionsRow extends StatelessWidget {
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});
final FeedBlogPostModel post;
final bool isLiked;
final int totalLikes;
final Future<void> Function() onToggleLike;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
onTap: onToggleLike,
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
text: post.totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
onTap: () {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
post.totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
),
);
}
}
String _truncateDescription(String description) {
final isLong = description.length > 200;
if (!isLong) return description;
var cutPoint = 200;
final preview = description.substring(0, cutPoint);
final boldOpen = (preview.split('**').length - 1) ~/ 2;
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
final hasUnclosedBold = boldOpen > boldClose;
if (hasUnclosedBold) {
final searchEnd = (cutPoint + 100).clamp(0, description.length);
final boldClosePos = description.indexOf('**', cutPoint);
if (boldClosePos != -1 && boldClosePos < searchEnd) {
cutPoint = boldClosePos + 2;
} else {
cutPoint = description.lastIndexOf(' ', 200);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
} else {
cutPoint = description.lastIndexOf(' ', cutPoint);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
return description.substring(0, cutPoint);
}

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
import 'package:flutter/material.dart';
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
class BlogPosts extends StatelessWidget {
const BlogPosts({super.key, required this.handleVisibility});
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility;
final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
child: BlocBuilder<FeedCubit, FeedState>(
builder: (BuildContext context, FeedState state) {
if (state is FeedStateLoaded) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
return false;
},
child: ListView.separated(
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
if (index == state.feedBlogPostList.length) {
return const Loader();
}
final post = state.feedBlogPostList[index];
return VisibilityDetector(
key: ValueKey(post.id),
onVisibilityChanged: (info) {
final id = state.feedBlogPostList[index].id;
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
},
key: ValueKey(index),
child: BlogPostWidget(index: index),
child: BlogPostWidget(post: post),
);
},
separatorBuilder: (context, index) => const SizedBox(height: 8),
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
),
);
} else if (state is FeedStateError) {
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList());
return DataErrorWidget(
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
);
} else {
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
}

View File

@@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
enum FeedVideoEmbedProvider { vk, other }
/// Сборка URL и HTML для embed-плеера ленты.
abstract final class FeedVideoEmbed {
static FeedVideoEmbedProvider detectProvider(Uri uri) {
final host = uri.host.toLowerCase();
final isVk = host == 'vk.com' ||
host.endsWith('.vk.com') ||
host == 'vk.ru' ||
host.endsWith('.vk.ru') ||
host == 'vkvideo.ru' ||
host.endsWith('.vkvideo.ru');
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
}
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
static Uri uriForPlayback(String url) {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
params['autoplay'] = '1';
params['mute'] = '1';
params.putIfAbsent('playsinline', () => '1');
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
params['js_api'] = '1';
}
return uri.replace(queryParameters: params);
}
static String buildHtml(String embedUrl) {
final uri = Uri.parse(embedUrl);
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
</style>
$headScripts
</head>
<body>
<iframe
id="player"
src="$src"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<script>
$bootstrap
</script>
</body>
</html>
''';
}
}

View File

@@ -0,0 +1,70 @@
/// JS-фрагменты для embed-плеера ленты.
///
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
abstract final class FeedVideoEmbedScripts {
static const readyChannel = 'EmbedReady';
/// Уведомляет Flutter, что embed готов к показу.
static String get notifyReady =>
'''
function notifyReady() {
try {
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
} catch (e) {}
}
''';
/// Уведомляет Flutter об ошибке воспроизведения.
static String get notifyError =>
'''
function notifyError() {
try {
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
} catch (e) {}
}
''';
/// Rutube: готовность через postMessage `player:canPlay`.
static String get rutubeCanPlayListener => '''
window.addEventListener('message', function (event) {
try {
var type = JSON.parse(event.data).type;
if (type === 'player:canPlay') {
notifyReady();
}
} catch (e) {}
});
''';
/// VK Video JS API: воспроизведение началось на событии `started`.
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
static String get vkPlayerReadyHandler => '''
try {
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
vkPlayer.on('started', function () {
notifyReady();
});
vkPlayer.on('error', function () {
notifyError();
});
}
} catch (e) {}
''';
/// Внешний скрипт VK VideoPlayer API.
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
static String bootstrap({required bool includeVk}) {
final vkPart = includeVk ? vkPlayerReadyHandler : '';
return '''
(function () {
$notifyReady
$notifyError
$rutubeCanPlayListener
$vkPart
})();
''';
}
}

View File

@@ -0,0 +1,212 @@
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Embed-видео в ленте.
///
/// 1) Пока пользователь не нажал play — только превью (без WebView).
/// 2) После play — превью + лоадер, WebView грузится скрыто.
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
///
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/VK/YouTube):
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
class FeedVideoPlayer extends StatefulWidget {
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
final String embedUrl;
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
final String? videoUrlThumb;
@override
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
}
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
static const Duration _readyFallbackDelay = Duration(seconds: 4);
/// Пока null — WebView не рендерится. Заполняется по жесту play.
InAppWebViewInitialData? _initialData;
bool _isLoading = false;
bool _isReady = false;
bool _hasError = false;
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
Timer? _readyFallback;
@override
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
_reset();
}
}
@override
void dispose() {
_readyFallback?.cancel();
super.dispose();
}
void _reset() {
_readyFallback?.cancel();
_readyFallback = null;
_initialData = null;
_isLoading = false;
_isReady = false;
_hasError = false;
}
void _markReady() {
if (!mounted || _isReady || _hasError) return;
_readyFallback?.cancel();
_readyFallback = null;
setState(() {
_isReady = true;
_isLoading = false;
});
}
void _markError() {
if (!mounted || _isReady) return;
_readyFallback?.cancel();
_readyFallback = null;
setState(() {
_hasError = true;
_isLoading = false;
_initialData = null;
});
}
Future<void> _onPlayPressed() async {
if (_isLoading || _isReady || !mounted) return;
setState(() {
_isLoading = true;
_hasError = false;
});
try {
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
if (!mounted) return;
setState(() {
_initialData = InAppWebViewInitialData(
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
mimeType: 'text/html',
encoding: 'utf8',
baseUrl: WebUri(baseUrl),
);
});
} catch (e, st) {
if (kDebugMode) {
debugPrint('FeedVideoPlayer load failed: $e\n$st');
}
_markError();
}
}
InAppWebViewSettings get _settings => InAppWebViewSettings(
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
iframeAllowFullscreen: true,
transparentBackground: false,
);
void _onWebViewCreated(InAppWebViewController controller) {
controller.addJavaScriptHandler(
handlerName: FeedVideoEmbedScripts.readyChannel,
callback: (args) {
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
if (status == 'error') {
_markError();
} else {
_markReady();
}
},
);
}
void _onLoadStop(InAppWebViewController _, WebUri? _) {
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
if (_isReady || _hasError || !mounted) return;
_readyFallback?.cancel();
_readyFallback = Timer(_readyFallbackDelay, () {
if (mounted && !_isReady && !_hasError) _markReady();
});
}
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
if (!mounted) return;
if (request.isForMainFrame != true) return;
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
if (_isReady) return;
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
_markError();
}
void _onEnterFullscreen(InAppWebViewController _) {
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
}
void _onExitFullscreen(InAppWebViewController _) {
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
SystemChrome.setPreferredOrientations([]);
}
@override
Widget build(BuildContext context) {
final player = ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
// WebView создаётся только после play и до ready остаётся скрытым под превью.
if (_initialData != null)
Opacity(
opacity: _isReady ? 1 : 0,
child: IgnorePointer(
ignoring: !_isReady,
child: InAppWebView(
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
initialData: _initialData,
initialSettings: _settings,
onWebViewCreated: _onWebViewCreated,
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
onLoadStop: _onLoadStop,
onReceivedError: _onReceivedError,
onEnterFullscreen: _onEnterFullscreen,
onExitFullscreen: _onExitFullscreen,
),
),
),
if (!_isReady)
FeedVideoPreviewPlaceholder(
previewUrl: widget.videoUrlThumb,
isLoading: _isLoading,
hasError: _hasError,
onPlay: _onPlayPressed,
onRetry: _onPlayPressed,
),
],
),
);
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
if (_isReady) return player;
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class FeedVideoPreviewPlaceholder extends StatelessWidget {
const FeedVideoPreviewPlaceholder({
super.key,
required this.previewUrl,
required this.isLoading,
required this.hasError,
required this.onPlay,
required this.onRetry,
});
final String? previewUrl;
final bool isLoading;
final bool hasError;
final VoidCallback onPlay;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
if (previewUrl != null && previewUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: previewUrl!,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
)
else
const ColoredBox(color: Color(0xFF2C2C2C)),
if (hasError)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
const SizedBox(height: 8),
TextButton(
onPressed: onRetry,
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
),
],
),
)
else if (isLoading)
const Center(child: CircularProgressIndicator(color: Colors.white))
else
Center(
child: GestureDetector(
onTap: onPlay,
child: Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
padding: const EdgeInsets.all(12),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
),
),
),
],
);
}
}

View File

@@ -0,0 +1 @@
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';

View File

@@ -1,9 +1,9 @@
import 'package:eatvio_app/features/feed/models/comment_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/feed/models/view_model.dart';
import 'package:eatvio_app/features/feed/services/feed_service.dart';
@@ -13,9 +13,12 @@ class FeedRepository {
FeedRepository({required this.feedService});
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
return (await feedService.getBlogPostList(page: page, query: query));
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
final response = await feedService.getFeed(search: search, cursor: cursor);
return (
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
nextCursor: response.meta.nextCursor,
);
}
Future<FeedBlogPostModel> getBlogPost({required int id}) async {

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/view_model.dart';
import 'package:retrofit/retrofit.dart';
@@ -18,7 +19,13 @@ abstract class FeedService {
@GET('/api/blogPosts/index')
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
@GET('/api/blogPosts/show/{postId}')
@GET('/api/feed')
Future<PostFeedPaginationModel> getFeed({
@Query('search') String? search,
@Query('cursor') String? cursor,
});
@GET('/api/v2/blogPosts/show/{postId}')
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
@GET('/api/recipes/index')

View File

@@ -1,19 +1,21 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
@@ -31,221 +33,213 @@ class ProfileBlogPostWidget extends StatefulWidget {
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
late bool isLiked;
late int totalLikes;
int currentGalleryIndex = 0;
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileCubit, ProfileState>(
bloc: widget.profileCubit,
builder: (BuildContext context, ProfileState state) {
if (state is ProfileStateLoaded) {
isLiked = state.blogPosts[widget.index].wasLikedByMe;
totalLikes = state.blogPosts[widget.index].totalLikes;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
child: Ink(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state is! ProfileStateLoaded) {
return const SizedBox.shrink();
}
final FeedBlogPostModel post = state.blogPosts[widget.index];
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
child: Ink(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
post.author.authorProfileIcon.contains('https')
? post.author.authorProfileIcon
: sl<AppConfig>().baseUrl + post.author.authorProfileIcon,
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.author.authorName,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(height: 1, color: Colors.black45),
),
],
),
const Spacer(),
CustomPopup(
isAuthor: post.author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}',
id: post.id,
feedBlogPostModel: post,
userSlug: post.author.authorSlug,
),
],
),
),
if (post.title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
? state.blogPosts[widget.index].author.authorProfileIcon
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.blogPosts[widget.index].author.authorName,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(height: 1, color: Colors.black45),
),
],
),
const Spacer(),
CustomPopup(
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
id: state.blogPosts[widget.index].id,
feedBlogPostModel: state.blogPosts[widget.index],
userSlug: state.blogPosts[widget.index].author.authorSlug,
),
],
child: Text(
post.title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
),
),
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
state.blogPosts[widget.index].title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
),
],
if (post.title.isEmpty) const SizedBox(height: 8),
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
if (post.description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: MarkdownHtmlWidget(
markdownText: _truncateDescription(post.description),
fontSize: 16,
textColor: Colors.black87,
),
],
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
if (state.blogPosts[widget.index].description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Builder(
builder: (context) {
String description = state.blogPosts[widget.index].description;
final isLong = description.length > 200;
if (isLong) {
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
int cutPoint = 200;
// Проверяем, есть ли незакрытые markdown теги
String preview = description.substring(0, cutPoint);
// Считаем открывающие и закрывающие теги
int boldOpen = (preview.split('**').length - 1) ~/ 2;
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
bool hasUnclosedBold = boldOpen > boldClose;
// Если есть незакрытый bold, ищем закрытие
if (hasUnclosedBold) {
int searchEnd = (cutPoint + 100).clamp(0, description.length);
int boldClosePos = description.indexOf('**', cutPoint);
if (boldClosePos != -1 && boldClosePos < searchEnd) {
cutPoint = boldClosePos + 2;
} else {
// Если не нашли, обрезаем до последнего пробела
cutPoint = description.lastIndexOf(' ', 200);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
} else {
// Обрезаем до последнего пробела для красоты
cutPoint = description.lastIndexOf(' ', cutPoint);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
description = description.substring(0, cutPoint);
}
return MarkdownHtmlWidget(
markdownText: description,
fontSize: 16,
textColor: Colors.black87,
),
if (post.description.length > 200)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery,
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
onTap: () async {
setState(() {
isLiked = !isLiked;
isLiked ? totalLikes++ : totalLikes--;
});
isLiked ? await context.read<FeedCubit>().likePost(post.id) : await context.read<FeedCubit>().unlikedPost(post.id);
widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true);
},
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
'assets/icons/comment.svg',
height: 20,
width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
text: post.totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
height: 20,
width: 20,
'assets/icons/share.svg',
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
onTap: () {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
),
),
if (state.blogPosts[widget.index].description.length > 200)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
post.totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
),
if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
GestureDetector(
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
);
},
child: Hero(
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
onTap: () async {
setState(() {
isLiked = !isLiked;
isLiked ? totalLikes++ : totalLikes--;
});
isLiked
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
},
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
'assets/icons/comment.svg',
height: 20,
width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
text: state.blogPosts[widget.index].totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
height: 20,
width: 20,
'assets/icons/share.svg',
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
onTap: () {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
state.blogPosts[widget.index].totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
),
),
const SizedBox(height: 8),
],
),
),
const SizedBox(height: 8),
],
),
);
}
return const SizedBox.shrink();
),
);
},
);
}
}
String _truncateDescription(String description) {
final isLong = description.length > 200;
if (!isLong) return description;
var cutPoint = 200;
final preview = description.substring(0, cutPoint);
final boldOpen = (preview.split('**').length - 1) ~/ 2;
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
final hasUnclosedBold = boldOpen > boldClose;
if (hasUnclosedBold) {
final searchEnd = (cutPoint + 100).clamp(0, description.length);
final boldClosePos = description.indexOf('**', cutPoint);
if (boldClosePos != -1 && boldClosePos < searchEnd) {
cutPoint = boldClosePos + 2;
} else {
cutPoint = description.lastIndexOf(' ', 200);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
} else {
cutPoint = description.lastIndexOf(' ', cutPoint);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
return description.substring(0, cutPoint);
}

View File

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