diff --git a/.kilocode/rules/memory-bank/context.md b/.kilocode/rules/memory-bank/context.md index 0bde3ccf..ff45966e 100644 --- a/.kilocode/rules/memory-bank/context.md +++ b/.kilocode/rules/memory-bank/context.md @@ -1,7 +1,7 @@ # Current Context ## Project Status -Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production. +Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production. ## Recent Activity - Memory Bank initialization completed @@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1. - Architecture and technology stack catalogued ## Current Focus -Ready for development tasks. No active work in progress. +Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`) +мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс +fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует +Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview` +делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play → +скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с +для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen. +Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`. ## Known Issues -None documented at initialization. +- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`, + `post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие, + связаны с fixtures/маппингом, не с видеоплеером. ## Next Steps Awaiting user direction for: diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 7cc78138..a97124f9 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -265,7 +265,15 @@ class OtherRoutes { ), // Consultant routes - GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), + GoRoute( + path: RouteConstants.becomeConsultantScreen, + name: 'becomeConsultantScreen', + builder: (context, state) { + final data = state.extra as Map?; + final slug = data?['slug'] as String?; + return BecomeConsultantScreen(slug: slug); + }, + ), GoRoute( path: RouteConstants.stepperBecomeConsultantScreen, name: 'stepperBecomeConsultantScreen', @@ -273,7 +281,12 @@ class OtherRoutes { final data = state.extra as Map; final slug = data['slug'] as String?; final isCanceled = data['isCanceled'] as bool?; - return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled); + final isNewRegistration = data['isNewRegistration'] as bool? ?? false; + return StepperBecomeConsultantScreen( + slug: slug, + isCanceled: isCanceled, + isNewRegistration: isNewRegistration, + ); }, ), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index ce691fb9..9067738c 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -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 { final CommentsCubit commentsCubit = sl(); final textEditingController = TextEditingController(); int? replyId; + int currentGalleryIndex = 0; @override void initState() { @@ -40,6 +44,10 @@ class _BlogPostScreenState extends State { super.initState(); } + void _openGallery(List 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 { child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), - if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) - CachedNetworkImage(imageUrl: '${sl().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( diff --git a/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart new file mode 100644 index 00000000..60d5b86c --- /dev/null +++ b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart @@ -0,0 +1,51 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +enum AlfaScoringStatus { + @JsonValue('none') + none, + @JsonValue('beneficiary_creating') + beneficiaryCreating, + @JsonValue('beneficiary_failed') + beneficiaryFailed, + @JsonValue('scoring_pending') + scoringPending, + @JsonValue('scoring_complete') + scoringComplete, + @JsonValue('scoring_failed') + scoringFailed; + + static AlfaScoringStatus fromString(String? value) { + switch (value) { + case 'beneficiary_creating': + return AlfaScoringStatus.beneficiaryCreating; + case 'beneficiary_failed': + return AlfaScoringStatus.beneficiaryFailed; + case 'scoring_pending': + return AlfaScoringStatus.scoringPending; + case 'scoring_complete': + return AlfaScoringStatus.scoringComplete; + case 'scoring_failed': + return AlfaScoringStatus.scoringFailed; + case 'none': + case null: + case '': + return AlfaScoringStatus.none; + default: + return AlfaScoringStatus.none; + } + } + + bool get isProcessing => + this == AlfaScoringStatus.beneficiaryCreating || + this == AlfaScoringStatus.scoringPending; + + bool get isFailed => + this == AlfaScoringStatus.beneficiaryFailed || + this == AlfaScoringStatus.scoringFailed; + + bool get canRetry => isFailed; + + bool get isComplete => this == AlfaScoringStatus.scoringComplete; + + bool get isTerminal => isComplete || isFailed; +} diff --git a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart index aaec9b68..79735be5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart @@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel { const ConsultantCertPhotoModel._(); - factory ConsultantCertPhotoModel.fromJson(Map json) => _$ConsultantCertPhotoModelFromJson(json); + factory ConsultantCertPhotoModel.fromJson(Map json) => + _$ConsultantCertPhotoModelFromJson(json); static const String visibleGroup = 'user_certs'; static const String hiddenGroup = 'hidden_user_certs'; diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index 535b58fd..5d454ff2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -6,27 +6,100 @@ part 'consultant_profile_model.g.dart'; @freezed abstract class ConsultantProfileModel with _$ConsultantProfileModel { + const ConsultantProfileModel._(); + const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, - @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'has_medical_education', includeIfNull: false) + bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) + SocialLinksModel? socialLinks, + @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, + @JsonKey(name: 'completed_courses', includeIfNull: false) + List? certificates, + @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, + @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, + @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, + @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, + @JsonKey(name: 'inn', includeIfNull: false) String? inn, + @JsonKey(name: 'snils', includeIfNull: false) String? snils, + @JsonKey(name: 'passport_series', includeIfNull: false) + String? passportSeries, + @JsonKey(name: 'passport_number', includeIfNull: false) + String? passportNumber, + @JsonKey(name: 'passport_issue_date', includeIfNull: false) + String? passportIssueDate, + @JsonKey(name: 'passport_issuer', includeIfNull: false) + String? passportIssuer, + @JsonKey(name: 'passport_issuer_code', includeIfNull: false) + String? passportIssuerCode, + @JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate, + @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, + @JsonKey(name: 'registration_address', includeIfNull: false) + String? registrationAddress, + @JsonKey(name: 'residence_address', includeIfNull: false) + String? residenceAddress, + @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, + @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) + bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) + String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) + bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) + bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; - factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + factory ConsultantProfileModel.fromJson(Map json) => + _$ConsultantProfileModelFromJson(json); + + bool get hasValidPassportData { + final last = lastName ?? ''; + final first = firstName ?? ''; + final innValue = inn ?? ''; + final snilsValue = snils ?? ''; + final series = passportSeries ?? ''; + final number = passportNumber ?? ''; + final issueDate = passportIssueDate ?? ''; + final issuer = passportIssuer ?? ''; + final issuerCode = passportIssuerCode ?? ''; + final birth = birthDate ?? ''; + final birthPlaceValue = birthPlace ?? ''; + final registration = registrationAddress ?? ''; + final residence = residenceAddress ?? ''; + final citizenshipValue = citizenship ?? ''; + + return last.trim().isNotEmpty && + first.trim().isNotEmpty && + RegExp(r'^\d{12}$').hasMatch(innValue) && + RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && + RegExp(r'^\d{4}$').hasMatch(series) && + RegExp(r'^\d{6}$').hasMatch(number) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && + issuer.trim().isNotEmpty && + RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && + birthPlaceValue.trim().isNotEmpty && + registration.trim().isNotEmpty && + residence.trim().isNotEmpty && + RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); + } } @freezed abstract class SocialLinksModel with _$SocialLinksModel { const factory SocialLinksModel({ - @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, - @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, - @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, - @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, + @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, + @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, + @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) + String? personalSiteLink, }) = _SocialLinksModel; - factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); + factory SocialLinksModel.fromJson(Map json) => + _$SocialLinksModelFromJson(json); } @freezed @@ -36,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel { required String date, }) = _CertificatesModel; - factory CertificatesModel.fromJson(Map json) => _$CertificatesModelFromJson(json); + factory CertificatesModel.fromJson(Map json) => + _$CertificatesModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 13cc0e77..c15649fd 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'rating_place') int? ratingPlace, @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, - @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'canceled_moderation') + CanceledModerationModel? canceledModeration, + @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; - factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); + factory ConsultationRequestModel.fromJson(Map json) => + _$ConsultationRequestModelFromJson(json); +} + +@freezed +abstract class LegalEntityModel with _$LegalEntityModel { + const LegalEntityModel._(); + + const factory LegalEntityModel({ + required int id, + required String type, + String? inn, + String? snils, + String? state, + @JsonKey( + name: 'alfa_scoring_status', + unknownEnumValue: AlfaScoringStatus.none, + defaultValue: AlfaScoringStatus.none, + ) + @Default(AlfaScoringStatus.none) + AlfaScoringStatus alfaScoringStatus, + @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, + @JsonKey(name: 'last_name') String? lastName, + @JsonKey(name: 'first_name') String? firstName, + @JsonKey(name: 'middle_name') String? middleName, + @JsonKey(name: 'passport_series') String? passportSeries, + @JsonKey(name: 'passport_number') String? passportNumber, + @JsonKey(name: 'passport_issue_date') String? passportIssueDate, + @JsonKey(name: 'passport_issuer') String? passportIssuer, + @JsonKey(name: 'passport_issuer_code') String? passportIssuerCode, + @JsonKey(name: 'birth_date') String? birthDate, + @JsonKey(name: 'birth_place') String? birthPlace, + @JsonKey(name: 'registration_address') String? registrationAddress, + @JsonKey(name: 'residence_address') String? residenceAddress, + String? citizenship, + @JsonKey(name: 'tax_resident_us') bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone') String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, + }) = _LegalEntityModel; + + factory LegalEntityModel.fromJson(Map json) => + _$LegalEntityModelFromJson(json); + + /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. + static String? normalizeApiDate(String? value) { + if (value == null || value.isEmpty) { + return null; + } + if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) { + return value.substring(0, 10); + } + return value; + } + + String? get normalizedPassportIssueDate => + normalizeApiDate(passportIssueDate); + + String? get normalizedBirthDate => normalizeApiDate(birthDate); } @freezed abstract class CertPhotosModel with _$CertPhotosModel { - const factory CertPhotosModel({ - required String url, - }) = _CertPhotosModel; + const factory CertPhotosModel({required String url}) = _CertPhotosModel; - factory CertPhotosModel.fromJson(Map json) => _$CertPhotosModelFromJson(json); + factory CertPhotosModel.fromJson(Map json) => + _$CertPhotosModelFromJson(json); } -enum ModerationStatus { - approved, - created, - canceled -} +enum ModerationStatus { approved, created, canceled } @freezed abstract class CanceledModerationModel with _$CanceledModerationModel { @@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel { required List? additional, }) = _CanceledModerationModel; - factory CanceledModerationModel.fromJson(Map json) => _$CanceledModerationModelFromJson(json); + factory CanceledModerationModel.fromJson(Map json) => + _$CanceledModerationModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart new file mode 100644 index 00000000..1ffb0c5f --- /dev/null +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sbp_bank_model.freezed.dart'; +part 'sbp_bank_model.g.dart'; + +@freezed +abstract class SbpBankModel with _$SbpBankModel { + const factory SbpBankModel({ + @JsonKey(name: 'bank_member_id') required String bankMemberId, + @JsonKey(name: 'bank_name') required String bankName, + @JsonKey(name: 'short_name') String? shortName, + @JsonKey(name: 'logo_url') String? logoUrl, + String? bic, + }) = _SbpBankModel; + + factory SbpBankModel.fromJson(Map json) => + _$SbpBankModelFromJson(json); +} diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 0410ae04..9f835e5c 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,42 +1,117 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:eatvio_app/core/error_reporting/report_from.dart'; class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; + final Duration scoringPollInterval; bool _update = false; + String? _userSlug; + Timer? _scoringPollTimer; - RegisterConsultantCubit({required this.consultantRepository}) - : super( - const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), - loading: true, - acceptOffer: false, - isUpdating: false, - ), - ); + static const String individualPersonLegalType = 'individualPerson'; + + RegisterConsultantCubit({ + required this.consultantRepository, + this.scoringPollInterval = const Duration(seconds: 3), + }) : super( + const RegisterConsultantState( + consultant: ConsultantProfileModel( + socialLinks: SocialLinksModel(), + hasMedicalEducation: true, + legalType: individualPersonLegalType, + ), + loading: true, + acceptOffer: false, + acceptFrdoAgreement: false, + isUpdating: false, + ), + ); + + String? get userSlug => _userSlug; + + void setUserSlug(String? slug) { + if (slug != null && slug.isNotEmpty) { + _userSlug = slug; + } + } + + Future> _loadSbpBanks() async { + try { + return await consultantRepository.getSbpBanks(); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + return const []; + } + } + + List searchSbpBanks(String query) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return state.sbpBanks; + } + return state.sbpBanks.where((bank) { + final name = bank.bankName.toLowerCase(); + final shortName = (bank.shortName ?? '').toLowerCase(); + final bic = (bank.bic ?? '').toLowerCase(); + return name.contains(normalized) || + shortName.contains(normalized) || + bic.contains(normalized); + }).toList(); + } + + SbpBankModel? findSbpBankById(String? bankMemberId) { + if (bankMemberId == null || bankMemberId.isEmpty) { + return null; + } + for (final bank in state.sbpBanks) { + if (bank.bankMemberId == bankMemberId) { + return bank; + } + } + return null; + } Future getMyRequestStatus(String slug) async { try { _update = true; - final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); + _userSlug = slug; + final myRequestStatus = await consultantRepository.getMyRequestStatus( + slug, + ); final certPhotos = await consultantRepository.getConsultantCertPhotos(); + final sbpBanks = await _loadSbpBanks(); - List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { - final course = courseMap.values.first; + List? certificates = myRequestStatus + .educationModel + .completedCourses + ?.map((courseMap) { + final course = courseMap.values.first; - return CertificatesModel(title: course.title, date: course.date); - }).toList(); + return CertificatesModel(title: course.title, date: course.date); + }) + .toList(); String? sanitizeLink(String? link) { return (link != null && link.isNotEmpty) ? link : null; } + final legal = myRequestStatus.legalEntity; + final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none; + emit( RegisterConsultantState( consultant: ConsultantProfileModel( @@ -46,33 +121,107 @@ class RegisterConsultantCubit extends Cubit { tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), - personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), + personalSiteLink: sanitizeLink( + myRequestStatus.educationModel.personalSiteLink, + ), ), - hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + hasMedicalEducation: + myRequestStatus.educationModel.hasMedicalEducation == 1, + legalType: legal?.type ?? individualPersonLegalType, + lastName: _emptyToNull(legal?.lastName), + firstName: _emptyToNull(legal?.firstName), + middleName: _emptyToNull(legal?.middleName), + inn: _emptyToNull(legal?.inn), + snils: _emptyToNull(legal?.snils), + passportSeries: _emptyToNull(legal?.passportSeries), + passportNumber: _emptyToNull(legal?.passportNumber), + passportIssueDate: legal?.normalizedPassportIssueDate, + passportIssuer: _emptyToNull(legal?.passportIssuer), + passportIssuerCode: _emptyToNull(legal?.passportIssuerCode), + birthDate: legal?.normalizedBirthDate, + birthPlace: _emptyToNull(legal?.birthPlace), + registrationAddress: _emptyToNull(legal?.registrationAddress), + residenceAddress: _emptyToNull(legal?.residenceAddress), + citizenship: _emptyToNull(legal?.citizenship), + taxResidentUs: legal?.taxResidentUs ?? false, + foreignTaxResident: legal?.foreignTaxResident ?? false, + sbpPhone: _emptyToNull(legal?.sbpPhone), + sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId), ), acceptOffer: false, + acceptFrdoAgreement: false, loading: false, requestModel: myRequestStatus, certPhotos: certPhotos, + sbpBanks: sbpBanks, isUpdating: _update, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + fieldErrors: const {}, error: null, ), ); + + if (scoringStatus.isProcessing) { + startScoringPoll(); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); + } + } + + String? _emptyToNull(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + return value.trim(); + } + + Future disableLoading({String? slug}) async { + try { + setUserSlug(slug); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); + final sbpBanks = await _loadSbpBanks(); + emit( + state.copyWith( + loading: false, + certPhotos: certPhotos, + sbpBanks: sbpBanks, + error: null, + fieldErrors: const {}, + awaitingScoringOutcome: false, + consultant: state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship, + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + ), + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); - } - } - - Future disableLoading() async { - try { - final certPhotos = await consultantRepository.getConsultantCertPhotos(); - emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null)); - } catch (e, st) { - await reportFrom(e, st, this); - log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -82,41 +231,325 @@ class RegisterConsultantCubit extends Cubit { if (slug != null) { await getMyRequestStatus(slug); } else { - await disableLoading(); + await disableLoading(slug: _userSlug); } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { - emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); + void updateConsultantProfileModel( + ConsultantProfileModel consultant, [ + bool? acceptOffer, + bool? acceptFrdoAgreement, + ]) { + emit( + state.copyWith( + consultant: consultant, + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + loading: false, + error: null, + fieldErrors: const {}, + ), + ); + } + + void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { + emit( + state.copyWith( + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + error: null, + ), + ); + } + + bool get hasAcceptedRequiredAgreements { + if (_update) { + return state.acceptOffer; + } + return state.acceptOffer && state.acceptFrdoAgreement; + } + + ConsultantProfileModel buildSubmitPayload() { + final withLegalType = state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship, + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + certificates: state.consultant.certificates + ?.map( + (course) => CertificatesModel( + title: course.title, + date: + LegalEntityModel.normalizeApiDate(course.date) ?? course.date, + ), + ) + .toList(), + ); + + if (_update) { + return ConsultantProfileModel( + hasMedicalEducation: withLegalType.hasMedicalEducation, + socialLinks: withLegalType.socialLinks, + photos: withLegalType.photos, + certificates: withLegalType.certificates, + legalType: withLegalType.legalType, + lastName: withLegalType.lastName, + firstName: withLegalType.firstName, + middleName: withLegalType.middleName, + inn: withLegalType.inn, + snils: withLegalType.snils, + passportSeries: withLegalType.passportSeries, + passportNumber: withLegalType.passportNumber, + passportIssueDate: withLegalType.passportIssueDate, + passportIssuer: withLegalType.passportIssuer, + passportIssuerCode: withLegalType.passportIssuerCode, + birthDate: withLegalType.birthDate, + birthPlace: withLegalType.birthPlace, + registrationAddress: withLegalType.registrationAddress, + residenceAddress: withLegalType.residenceAddress, + citizenship: withLegalType.citizenship, + taxResidentUs: withLegalType.taxResidentUs, + foreignTaxResident: withLegalType.foreignTaxResident, + sbpPhone: withLegalType.sbpPhone, + sbpBankMemberId: withLegalType.sbpBankMemberId, + ); + } + + return withLegalType.copyWith( + agreementAccepted: state.acceptOffer, + frdoAgreementAccepted: state.acceptFrdoAgreement, + ); } Future registerConsultant() async { - emit(state.copyWith(error: null)); + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } + + // Show scoring widget immediately while request / job are in progress. + emit( + state.copyWith( + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + awaitingScoringOutcome: true, + scoringRetryInProgress: false, + ), + ); + try { if (state.newAddedPhotos.isNotEmpty) { - await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); + await consultantRepository.storeConsultantCertPhotos( + files: state.newAddedPhotos, + ); } if (state.deletedPhotoIds.isNotEmpty) { for (final int id in state.deletedPhotoIds) { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: _update, + ); + _update = true; + emit( + state.copyWith( + isUpdating: true, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + scoringRetryInProgress: false, + awaitingScoringOutcome: true, + newAddedPhotos: const [], + deletedPhotoIds: const [], + fieldErrors: const {}, + error: null, + ), + ); + startScoringPoll(); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(error: e.toString())); + emit( + state.copyWith( + error: e.toString(), + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } } + Future retryScoring() async { + if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) { + return; + } + emit( + state.copyWith( + scoringRetryInProgress: true, + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + awaitingScoringOutcome: true, + ), + ); + _update = true; + try { + if (!state.consultant.hasValidPassportData) { + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: 'Заполните паспортные данные', + ), + ); + return; + } + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: true, + ); + startScoringPoll(); + emit( + state.copyWith( + scoringRetryInProgress: false, + isUpdating: true, + awaitingScoringOutcome: true, + ), + ); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + ), + ); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: e.toString(), + ), + ); + } + } + + void startScoringPoll() { + final slug = _userSlug; + if (slug == null || slug.isEmpty) { + emit( + state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false), + ); + return; + } + _stopScoringPoll(); + emit(state.copyWith(isScoringPolling: true)); + _scoringPollTimer = Timer.periodic( + scoringPollInterval, + (_) => _pollScoringStatus(slug), + ); + unawaited(_pollScoringStatus(slug)); + } + + Future _pollScoringStatus(String slug) async { + if (isClosed) { + return; + } + try { + final status = await consultantRepository.getMyRequestStatus(slug); + if (isClosed) { + return; + } + final scoringStatus = + status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none; + // API stays on `none` until ProcessConsultantScoringJob starts + // (ScoringStarted → beneficiary_creating). Keep optimistic processing + // UI and continue polling until a real terminal/processing status arrives. + if (scoringStatus == AlfaScoringStatus.none) { + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + ), + ); + return; + } + final isTerminal = scoringStatus.isTerminal; + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + // Clear session flag on terminal so success sheet can fire once + // (listenWhen: previous.awaiting && !current.awaiting && complete). + awaitingScoringOutcome: isTerminal + ? false + : state.awaitingScoringOutcome, + ), + ); + if (!scoringStatus.isProcessing) { + _stopScoringPoll(); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + // Keep polling on transient errors while scoring is in progress. + } + } + + void _stopScoringPoll() { + _scoringPollTimer?.cancel(); + _scoringPollTimer = null; + } + Future addCertPhotos(List files) async { if (files.isEmpty) { return; } try { - final compressedFiles = await ImageCompressUtility.compressFilesList(files); + final compressedFiles = await ImageCompressUtility.compressFilesList( + files, + ); - emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null)); + emit( + state.copyWith( + newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], + error: null, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -130,7 +563,19 @@ class RegisterConsultantCubit extends Cubit { return; } if (path != null) { - emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); + emit( + state.copyWith( + newAddedPhotos: state.newAddedPhotos + .where((file) => file.uri.path != path) + .toList(), + ), + ); } } + + @override + Future close() { + _stopScoringPoll(); + return super.close(); + } } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index e9635eb6..42daeb1e 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'register_consultant_state.freezed.dart'; @@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { const factory RegisterConsultantState({ required ConsultantProfileModel consultant, required bool acceptOffer, + @Default(false) bool acceptFrdoAgreement, required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, - @Default([]) List certPhotos, + @Default([]) + List certPhotos, + @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, + @Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus, + @Default(false) bool isScoringPolling, + @Default(false) bool scoringRetryInProgress, + + /// True only after successful register/retry in this session until scoring is terminal. + @Default(false) bool awaitingScoringOutcome, + @Default({}) Map fieldErrors, String? error, }) = _RegisterConsultantState; } diff --git a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart index ce13f905..90b673a5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart @@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget { const AdditionalSettingsScreen({super.key}); @override - State createState() => _AdditionalSettingsScreenState(); + State createState() => + _AdditionalSettingsScreenState(); } class _AdditionalSettingsScreenState extends State { @@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State { @override void initState() { - if (context.read().state.consultant.hasMedicalEducation != null) { + if (context + .read() + .state + .consultant + .hasMedicalEducation != + null) { haveMedicalDegree = context .read() .state @@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State { value: publishDiplomasAndCertificatesInProfile, onTap: () { setState(() { - publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile; + publishDiplomasAndCertificatesInProfile = + !publishDiplomasAndCertificatesInProfile; }); }, ), @@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State { setState(() { haveMedicalDegree = !haveMedicalDegree; }); - context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - hasMedicalEducation: haveMedicalDegree, - ), - ); + context + .read() + .updateConsultantProfileModel( + context + .read() + .state + .consultant + .copyWith(hasMedicalEducation: haveMedicalDegree), + ); }, ), ], diff --git a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart index 40d4a352..2955ee09 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class BecomeConsultantScreen extends StatefulWidget { - const BecomeConsultantScreen({super.key}); + final String? slug; + + const BecomeConsultantScreen({super.key, this.slug}); @override State createState() => _BecomeConsultantScreenState(); @@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State { title: const Text('Стать консультантом'), automaticallyImplyLeading: false, centerTitle: false, - leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)), + leading: IconButton( + onPressed: () => GoRouter.of(context).pop(), + icon: const Icon(Icons.close), + ), ), body: const Padding( padding: EdgeInsets.all(16), @@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State { InkWell _startRegistrationButton() { return InkWell( - onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}), + onTap: () => GoRouter.of(context).pushReplacementNamed( + 'stepperBecomeConsultantScreen', + extra: {'slug': widget.slug, 'isNewRegistration': true}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), child: const Center( child: Text( 'Начать регистрацию', - style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), + style: TextStyle( + color: Colors.white, + height: 1, + fontWeight: FontWeight.w600, + fontSize: 18, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index b2b26c0e..33772eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -7,15 +7,18 @@ import 'package:flutter/material.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @override - State createState() => _CertificatesAndDiplomasScreenState(); + State createState() => + _CertificatesAndDiplomasScreenState(); } -class _CertificatesAndDiplomasScreenState extends State { +class _CertificatesAndDiplomasScreenState + extends State { final scrollController = ScrollController(); List diplomasList = [ @@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State().state.consultant.certificates != null) { + if (context.read().state.consultant.certificates != + null) { diplomasList = [ - ...context.read().state.consultant.certificates!.map( + ...context + .read() + .state + .consultant + .certificates! + .map( (e) => DiplomaEntity( date: DateTime.tryParse(e.date), textEditingController: TextEditingController(text: e.title), @@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State _diplomaInfo(diploma: diplomasList[index]), + itemBuilder: (context, index) => + _diplomaInfo(diploma: diplomasList[index]), separatorBuilder: (context, index) => Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: _divider(context), @@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist( - 0, - diplomasList.length - 1, - ), + context.read().state.consultant.copyWith( + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null + ? '' + : DateFormat('yyyy-MM-dd').format(e.date!), ), - ); + ) + .toList() + .sublist(0, diplomasList.length - 1), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index 734387f1..5e87ba50 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -1,8 +1,11 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget { } class _LastStepScreenState extends State { - bool checkboxState = false; + static const _frdoUrl = + 'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/'; + + late final TapGestureRecognizer _frdoTapRecognizer; + + @override + void initState() { + super.initState(); + _frdoTapRecognizer = TapGestureRecognizer() + ..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + } + + @override + void dispose() { + _frdoTapRecognizer.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final cubit = context.watch(); + final state = cubit.state; + return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 80, top: 16), child: Column( @@ -27,34 +49,138 @@ class _LastStepScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Последний шаг', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), + if (state.awaitingScoringOutcome || + state.isScoringPolling || + state.alfaScoringStatus != + AlfaScoringStatus.none) ...[ + const SizedBox(height: 16), + const ScoringStatusWidget(), + ], + if (state.requestModel?.canceledModeration != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0x33FFCC00), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.requestModel!.canceledModeration!.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + if (state.requestModel!.canceledModeration!.additional != + null) ...[ + const SizedBox(height: 8), + for (final item + in state + .requestModel! + .canceledModeration! + .additional!) ...[ + Text( + '• $item', + style: const TextStyle(fontSize: 14, height: 1.3), + ), + ], + ], + ], + ), + ), + ), + ], const SizedBox(height: 16), CheckboxListTile( - value: checkboxState, + value: state.acceptOffer, title: Text( hidePlans ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' - : 'Я ознакомлен и принимаю договор публичной офёрты', - style: const TextStyle(fontSize: 18, height: 1), + : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности', + style: const TextStyle(fontSize: 18, height: 1.2), ), - onChanged: (_) { - setState(() { - checkboxState = !checkboxState; - }); - context.read().updateConsultantProfileModel(context.read().state.consultant, checkboxState); + onChanged: (value) { + cubit.updateAgreements(acceptOffer: value ?? false); }, activeColor: appTheme.blueA400, contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + if (!state.isUpdating) ...[ + CheckboxListTile( + value: state.acceptFrdoAgreement, + title: Text.rich( + TextSpan( + style: const TextStyle( + fontSize: 18, + height: 1.2, + color: Color(0xFF323F4B), + ), + children: [ + const TextSpan( + text: + 'Обязательное требование: Один из моих документов об образовании есть в реестре ', + ), + TextSpan( + text: 'ФРДО', + style: TextStyle( + color: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: _frdoTapRecognizer, + ), + ], + ), + ), + onChanged: (value) { + cubit.updateAgreements(acceptFrdoAgreement: value ?? false); + }, + activeColor: appTheme.blueA400, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), ), - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( - onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf')), + onPressed: () async => await launchUrl( + Uri.parse( + hidePlans + ? '${sl().baseUrl}/docs/privacy.pdf' + : '${sl().baseUrl}/oferta_bs.pdf', + ), + ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', - style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), + hidePlans + ? 'Политика конфиденциальности и пользовательское соглашение' + : 'Договор оферты', + style: const TextStyle( + height: 1, + fontSize: 16, + color: Color(0xFF323F4B), + decoration: TextDecoration.underline, + ), ), ), ), @@ -67,7 +193,10 @@ class _LastStepScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart new file mode 100644 index 00000000..466f4418 --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -0,0 +1,591 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +class PassportDataScreen extends StatefulWidget { + const PassportDataScreen({super.key}); + + @override + State createState() => _PassportDataScreenState(); +} + +class _PassportDataScreenState extends State { + late final TextEditingController lastNameController; + late final TextEditingController firstNameController; + late final TextEditingController middleNameController; + late final TextEditingController innController; + late final TextEditingController snilsController; + late final TextEditingController seriesController; + late final TextEditingController numberController; + late final TextEditingController issueDateController; + late final TextEditingController issuerController; + late final TextEditingController issuerCodeController; + late final TextEditingController birthDateController; + late final TextEditingController birthPlaceController; + late final TextEditingController citizenshipController; + late final TextEditingController registrationAddressController; + late final TextEditingController residenceAddressController; + late final TextEditingController sbpPhoneController; + late final TextEditingController sbpBankController; + final sbpBankSuggestionsController = SuggestionsController(); + + bool taxResidentUs = false; + bool foreignTaxResident = false; + String? selectedSbpBankMemberId; + String? selectedSbpBankName; + + final snilsMask = MaskTextInputFormatter( + mask: '###-###-### ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskIssue = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskBirth = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final issuerCodeMask = MaskTextInputFormatter( + mask: '###-###', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final phoneMask = MaskTextInputFormatter( + mask: '+7 (###) ### ## ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + + @override + void initState() { + super.initState(); + final consultant = context.read().state.consultant; + lastNameController = TextEditingController(text: consultant.lastName ?? ''); + firstNameController = TextEditingController( + text: consultant.firstName ?? '', + ); + middleNameController = TextEditingController( + text: consultant.middleName ?? '', + ); + innController = TextEditingController(text: consultant.inn ?? ''); + snilsController = TextEditingController(text: consultant.snils ?? ''); + seriesController = TextEditingController( + text: consultant.passportSeries ?? '', + ); + numberController = TextEditingController( + text: consultant.passportNumber ?? '', + ); + issueDateController = TextEditingController( + text: _apiDateToUi(consultant.passportIssueDate), + ); + issuerController = TextEditingController( + text: consultant.passportIssuer ?? '', + ); + issuerCodeController = TextEditingController( + text: consultant.passportIssuerCode ?? '', + ); + birthDateController = TextEditingController( + text: _apiDateToUi(consultant.birthDate), + ); + birthPlaceController = TextEditingController( + text: consultant.birthPlace ?? '', + ); + citizenshipController = TextEditingController(text: consultant.citizenship); + registrationAddressController = TextEditingController( + text: consultant.registrationAddress ?? '', + ); + residenceAddressController = TextEditingController( + text: consultant.residenceAddress ?? '', + ); + sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? ''); + selectedSbpBankMemberId = consultant.sbpBankMemberId; + final selectedBank = context + .read() + .findSbpBankById(consultant.sbpBankMemberId); + selectedSbpBankName = selectedBank?.bankName; + sbpBankController = TextEditingController(text: selectedSbpBankName ?? ''); + taxResidentUs = consultant.taxResidentUs ?? false; + foreignTaxResident = consultant.foreignTaxResident ?? false; + } + + void _hydrateSbpBankNameIfNeeded() { + if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) { + return; + } + if (sbpBankController.text.trim().isNotEmpty) { + return; + } + final selectedBank = context + .read() + .findSbpBankById(selectedSbpBankMemberId); + if (selectedBank == null) { + return; + } + selectedSbpBankName = selectedBank.bankName; + sbpBankController.text = selectedBank.bankName; + } + + @override + void dispose() { + lastNameController.dispose(); + firstNameController.dispose(); + middleNameController.dispose(); + innController.dispose(); + snilsController.dispose(); + seriesController.dispose(); + numberController.dispose(); + issueDateController.dispose(); + issuerController.dispose(); + issuerCodeController.dispose(); + birthDateController.dispose(); + birthPlaceController.dispose(); + citizenshipController.dispose(); + registrationAddressController.dispose(); + residenceAddressController.dispose(); + sbpPhoneController.dispose(); + sbpBankController.dispose(); + sbpBankSuggestionsController.dispose(); + super.dispose(); + } + + String _apiDateToUi(String? apiDate) { + if (apiDate == null || apiDate.isEmpty) { + return ''; + } + final parts = apiDate.split('-'); + if (parts.length != 3) { + return apiDate; + } + return '${parts[2]}.${parts[1]}.${parts[0]}'; + } + + String? _uiDateToApi(String uiDate) { + final digits = uiDate.replaceAll(RegExp(r'\D'), ''); + if (digits.length != 8) { + return null; + } + return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}'; + } + + String? _uiPhoneToApi(String uiPhone) { + final digits = uiPhone.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) { + return null; + } + if (digits.length == 11 && digits.startsWith('7')) { + return '+$digits'; + } + if (digits.length == 10) { + return '+7$digits'; + } + return uiPhone.trim().isEmpty ? null : uiPhone.trim(); + } + + void _syncToCubit() { + final cubit = context.read(); + final current = cubit.state.consultant; + cubit.updateConsultantProfileModel( + current.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + lastName: lastNameController.text.trim().isEmpty + ? null + : lastNameController.text.trim(), + firstName: firstNameController.text.trim().isEmpty + ? null + : firstNameController.text.trim(), + middleName: middleNameController.text.trim().isEmpty + ? null + : middleNameController.text.trim(), + inn: innController.text.trim().isEmpty + ? null + : innController.text.trim(), + snils: snilsController.text.trim().isEmpty + ? null + : snilsController.text.trim(), + passportSeries: seriesController.text.trim().isEmpty + ? null + : seriesController.text.trim(), + passportNumber: numberController.text.trim().isEmpty + ? null + : numberController.text.trim(), + passportIssueDate: _uiDateToApi(issueDateController.text), + passportIssuer: issuerController.text.trim().isEmpty + ? null + : issuerController.text.trim(), + passportIssuerCode: issuerCodeController.text.trim().isEmpty + ? null + : issuerCodeController.text.trim(), + birthDate: _uiDateToApi(birthDateController.text), + birthPlace: birthPlaceController.text.trim().isEmpty + ? null + : birthPlaceController.text.trim(), + citizenship: citizenshipController.text.trim().isEmpty + ? 'RU' + : citizenshipController.text.trim().toUpperCase(), + registrationAddress: registrationAddressController.text.trim().isEmpty + ? null + : registrationAddressController.text.trim(), + residenceAddress: residenceAddressController.text.trim().isEmpty + ? null + : residenceAddressController.text.trim(), + taxResidentUs: taxResidentUs, + foreignTaxResident: foreignTaxResident, + sbpPhone: _uiPhoneToApi(sbpPhoneController.text), + sbpBankMemberId: selectedSbpBankMemberId, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + } + + void _selectSbpBank(SbpBankModel bank) { + setState(() { + selectedSbpBankMemberId = bank.bankMemberId; + selectedSbpBankName = bank.bankName; + sbpBankController.text = bank.bankName; + }); + _syncToCubit(); + } + + void _clearSbpBankIfNeeded(String value) { + if (selectedSbpBankMemberId != null && value != selectedSbpBankName) { + setState(() { + selectedSbpBankMemberId = null; + selectedSbpBankName = null; + }); + _syncToCubit(); + } + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, + listener: (context, state) => _hydrateSbpBankNameIfNeeded(), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.fieldErrors != current.fieldErrors, + builder: (context, state) { + final errors = state.fieldErrors; + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PassportScreenTitle(title: 'Паспортные данные'), + const PassportSectionTitle( + title: 'Персональные данные (как в паспорте)', + ), + PassportFieldWithError( + error: errors['last_name'], + child: CustomTextFormField( + hintText: 'Фамилия', + controller: lastNameController, + hasError: errors.containsKey('last_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['first_name'], + child: CustomTextFormField( + hintText: 'Имя', + controller: firstNameController, + hasError: errors.containsKey('first_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['middle_name'], + child: CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + hasError: errors.containsKey('middle_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Идентификация'), + PassportFieldWithError( + error: errors['inn'], + child: CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + hasError: errors.containsKey('inn'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['snils'], + child: CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + hasError: errors.containsKey('snils'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_date'], + child: CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + hasError: errors.containsKey('birth_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_place'], + child: CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + hasError: errors.containsKey('birth_place'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['citizenship'], + child: CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + hasError: errors.containsKey('citizenship'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['registration_address'], + child: CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + hasError: errors.containsKey('registration_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['residence_address'], + child: CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + hasError: errors.containsKey('residence_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь налоговым резидентом США', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + const PassportSectionTitle(title: 'Паспорт'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: PassportFieldWithError( + error: errors['passport_series'], + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + hasError: errors.containsKey('passport_series'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: PassportFieldWithError( + error: errors['passport_number'], + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + hasError: errors.containsKey('passport_number'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issue_date'], + child: CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + hasError: errors.containsKey('passport_issue_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer_code'], + child: CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + hasError: errors.containsKey('passport_issuer_code'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer'], + child: CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + hasError: errors.containsKey('passport_issuer'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Реквизиты СБП'), + PassportFieldWithError( + error: errors['sbp_phone'], + child: CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + hasError: errors.containsKey('sbp_phone'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['sbp_bank_member_id'], + child: TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => + CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + hasError: errors.containsKey('sbp_bank_member_id'), + onChanged: _clearSbpBankIfNeeded, + ), + suggestionsCallback: (query) async { + return context + .read() + .searchSbpBanks(query); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: TextStyles.textMMedium.copyWith( + color: appTheme.black900, + ), + ), + subtitle: + bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: appTheme.whiteA700, + child: child, + ), + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ), + onSelected: _selectSbpBank, + ), + ), + const SizedBox(height: 16), + Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyles.textSMedium.copyWith( + color: appTheme.blueA400, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart index cf21b646..87269eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart @@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + const Text( + 'Загрузите фото или сканы дипломов и сертификатов', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), const SizedBox(height: 16), BlocBuilder( builder: (context, state) { - final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); + final photos = state.certPhotos + .where((photo) => !state.deletedPhotoIds.contains(photo.id)) + .toList(); final addedPhotos = state.newAddedPhotos; final totalPhotosCount = photos.length + addedPhotos.length; return Column( children: [ - if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], + if (isUploading) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 16), + ], GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 3 / 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 3 / 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), itemBuilder: (BuildContext context, int index) { if (index == totalPhotosCount) { return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _pickAndUploadPhotos(), child: Ink( - decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: const Color(0xFFF6F6F6), + borderRadius: BorderRadius.circular(20), + ), child: Icon(Icons.add, color: appTheme.blueA400), ), ); @@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State { return _LocalCertPhotoCard( photo: localPhoto, allPhotos: addedPhotos, - onRemove: () => _removePhoto(path: localPhoto.uri.path), + onRemove: () => + _removePhoto(path: localPhoto.uri.path), ); } } @@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State { }); try { // ignore: use_build_context_synchronously - await context.read().addCertPhotos(newImages.map((file) => File(file.path)).toList()); + await context.read().addCertPhotos( + newImages.map((file) => File(file.path)).toList(), + ); } finally { if (mounted) { setState(() { @@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _CertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget { onTap: () { context.pushTransparentRoute( rootNavigator: true, - PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)), + PhotoViewScreen( + imageUrls: allPhotos.map((e) => e.url).toList(), + isLocal: false, + index: allPhotos.indexOf(photo), + ), ); }, borderRadius: BorderRadius.circular(20), @@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget { Ink( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(20)), - image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)), + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage(photo.url), + ), ), ), Positioned( @@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), @@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _LocalCertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index b8fed20b..e10fc74a 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class RegistrationAsScreen extends StatefulWidget { const RegistrationAsScreen({super.key}); @@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = + RegistrationAsType.physical; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final cubit = context.read(); + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + }); + } @override Widget build(BuildContext context) { @@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Регистрация в качестве', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), const SizedBox(height: 16), Column( @@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: (_) {}, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Физического лица', + style: TextStyle(fontSize: 18, height: 1), + ), ), ), ], @@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Самозанятого', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Индивидуального предпринимателя', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Общества с ограниченной ответственностью', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], ), ], ), - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), + child: Text( + '* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся', + ), ), ], ), @@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart index 609e94a5..1847ec60 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart @@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State { var maskFormatter = MaskTextInputFormatter( mask: 'https://########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskTgFormatter = MaskTextInputFormatter( mask: 'https://tg.me/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskVkFormatter = MaskTextInputFormatter( mask: 'https://vk.com/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); @override void initState() { super.initState(); - vkController.text = context.read().state.consultant.socialLinks!.vkLink ?? ''; - telegramController.text = context.read().state.consultant.socialLinks!.tgLink ?? ''; - webSiteController.text = context.read().state.consultant.socialLinks!.personalSiteLink ?? ''; + vkController.text = + context + .read() + .state + .consultant + .socialLinks! + .vkLink ?? + ''; + telegramController.text = + context + .read() + .state + .consultant + .socialLinks! + .tgLink ?? + ''; + webSiteController.text = + context + .read() + .state + .consultant + .socialLinks! + .personalSiteLink ?? + ''; } @override @@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/vk.svg', - ), + child: SvgPicture.asset('assets/icons/vk.svg'), ), const SizedBox(width: 8), SizedBox( @@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/telegram.svg', - ), + child: SvgPicture.asset('assets/icons/telegram.svg'), ), const SizedBox(width: 8), SizedBox( @@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/web.svg', - ), + child: SvgPicture.asset('assets/icons/web.svg'), ), const SizedBox(width: 8), SizedBox( @@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State { String? personalSiteLink, }) { context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - socialLinks: context.read().state.consultant.socialLinks?.copyWith( - tgLink: tgLink == null - ? context.read().state.consultant.socialLinks?.tgLink - : tgLink.isEmpty - ? null - : tgLink, - vkLink: vkLink == null - ? context.read().state.consultant.socialLinks?.vkLink - : vkLink.isEmpty - ? null - : vkLink, - personalSiteLink: personalSiteLink == null - ? context.read().state.consultant.socialLinks?.personalSiteLink - : personalSiteLink.isEmpty - ? null - : personalSiteLink, - ), - ), - ); + context.read().state.consultant.copyWith( + socialLinks: context + .read() + .state + .consultant + .socialLinks + ?.copyWith( + tgLink: tgLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.tgLink + : tgLink.isEmpty + ? null + : tgLink, + vkLink: vkLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.vkLink + : vkLink.isEmpty + ? null + : vkLink, + personalSiteLink: personalSiteLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.personalSiteLink + : personalSiteLink.isEmpty + ? null + : personalSiteLink, + ), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index a27e5c67..12b016af 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -1,11 +1,13 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; @@ -21,39 +23,145 @@ import 'package:lottie/lottie.dart'; class StepperBecomeConsultantScreen extends StatefulWidget { final String? slug; final bool? isCanceled; - const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); + final bool isNewRegistration; + const StepperBecomeConsultantScreen({ + super.key, + this.slug, + this.isCanceled, + this.isNewRegistration = false, + }); @override - State createState() => _StepperBecomeConsultantScreenState(); + State createState() => + _StepperBecomeConsultantScreenState(); } -class _StepperBecomeConsultantScreenState extends State { - double progressValue = 1 / 6; +class _StepperBecomeConsultantScreenState + extends State { + double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; bool isLoading = false; + bool _successSheetShown = false; @override void initState() { super.initState(); - if (widget.slug == null) { - registrationConsultantCubit = sl()..disableLoading(); + if (widget.isNewRegistration || widget.slug == null) { + registrationConsultantCubit = sl() + ..disableLoading(slug: widget.slug); } else { - registrationConsultantCubit = sl()..getMyRequestStatus(widget.slug!); + registrationConsultantCubit = sl() + ..getMyRequestStatus(widget.slug!); } } + static const int totalSteps = 7; + + List _pages() { + return const [ + CertificatesAndDiplomasScreen(), + PhotoOrScansScreen(), + AdditionalSettingsScreen(), + SocialNetworksScreen(), + RegistrationAsScreen(), + PassportDataScreen(), + LastStepScreen(), + ]; + } + + Future _showSuccessSheet(BuildContext context) async { + if (_successSheetShown) { + return; + } + _successSheetShown = true; + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + height: 1, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Lottie.asset( + 'assets/lottie_animations/success.json', + width: 160, + height: 160, + ), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + height: 1, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return BlocProvider( create: (context) => registrationConsultantCubit, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => + previous.awaitingScoringOutcome && + !current.awaitingScoringOutcome && + current.alfaScoringStatus.isComplete, + listener: (context, state) { + _showSuccessSheet(context); + }, builder: (context, state) { if (state.loading) { return const Scaffold(body: Loader()); } - if (state.error != null) { + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { return Scaffold( appBar: AppBar( centerTitle: true, @@ -72,248 +180,320 @@ class _StepperBecomeConsultantScreenState extends State context.read().reloadData(widget.slug), + onReload: () => context + .read() + .reloadData(widget.slug), ), ); } + + if (progressValue == 0) { + progressValue = 1 / totalSteps; + } + + final submitDisabled = + isLoading || + state.isScoringPolling || + state.scoringRetryInProgress; + return Scaffold( - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: Row( - children: [ - Container( - height: 48, - width: MediaQuery.of(context).size.width * 0.2, - padding: const EdgeInsets.only(left: 16, right: 4), - child: FloatingActionButton.extended( - heroTag: 'backButtonRegistration', - highlightElevation: 0, - focusElevation: 0, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: const Color(0xFFEAF2FD), - onPressed: () async { - setState(() { - pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); - }, - label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400), + floatingActionButtonLocation: + FloatingActionButtonLocation.centerFloat, + floatingActionButton: Row( + children: [ + Container( + height: 48, + width: MediaQuery.of(context).size.width * 0.2, + padding: const EdgeInsets.only(left: 16, right: 4), + child: FloatingActionButton.extended( + heroTag: 'backButtonRegistration', + highlightElevation: 0, + focusElevation: 0, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: const Color(0xFFEAF2FD), + onPressed: () async { + setState(() { + pageController.previousPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); + }, + label: Icon( + Icons.arrow_back_ios_new_rounded, + color: appTheme.blueA400, ), ), - if (widget.isCanceled == true) - InkWell( - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), + ), + if (widget.isCanceled == true && step != totalSteps) + InkWell( + onTap: () { + final canceledModeration = + state.requestModel?.canceledModeration; + if (canceledModeration == null) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(canceledModeration.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (canceledModeration.additional != null) + for (final item + in canceledModeration.additional!) ...[ + Text('• $item'), ], - ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ], ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: const Color(0x33FFCC00), + ), + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + height: 1, ), ), ), ), - Container( - height: 48, - width: widget.isCanceled == true - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: isLoading - ? Colors.grey - : widget.isCanceled == true - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: isLoading - ? null - : () async { - if (step == 6) { - if (widget.isCanceled == true) { - return; - } - if (registrationConsultantCubit.state.acceptOffer) { - setState(() { - isLoading = true; - }); - try { - await registrationConsultantCubit.registerConsultant(); - if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), - ), - ), - ), - ], - ), + ), + Container( + height: 48, + width: widget.isCanceled == true && step != totalSteps + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: submitDisabled + ? Colors.grey + : widget.isCanceled == true && step != totalSteps + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: submitDisabled + ? null + : () async { + if (step == totalSteps) { + if (!state.consultant.hasValidPassportData) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: const Text( + 'Заполните паспортные данные', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => + Navigator.of(context).pop(), ), - ); - } catch (e) { - if (!context.mounted) return; - showDialog( + ], + ), + ); + return; + } + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit + .registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit.state.error != + null) { + showDialog( context: context, builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: Text(e.toString()), + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text( + registrationConsultantCubit + .state + .error!, + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), ); - } finally { - setState(() { - isLoading = false; - }); + return; } - } else { - showDialog( + // Success sheet is shown by BlocListener when scoring completes. + } catch (e) { + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Примите договор публичной офёрты'), + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: () => + Navigator.of(context).pop(), ), ], ), ); + } finally { + setState(() { + isLoading = false; + }); } } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); } - }, - label: isLoading - ? const CircularProgressIndicator() - : widget.isCanceled == true - ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) - : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), - ), + } else { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); + } + }, + label: submitDisabled && step == totalSteps + ? const CircularProgressIndicator() + : widget.isCanceled == true && step != totalSteps + ? Icon( + Icons.arrow_forward_ios_outlined, + color: appTheme.blueA400, + ) + : Text( + step == totalSteps ? 'Отправить' : 'Продолжить', + style: theme.textTheme.titleSmall!.copyWith( + color: Colors.white, + ), + ), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), ), - centerTitle: true, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween(begin: 1 / 6, end: progressValue), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => GoRouter.of(context).pop(), + ), + ), + centerTitle: true, + title: SvgPicture.asset( + ImageConstant.imgLogoEatvio, + height: 48, + fit: BoxFit.contain, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('шаг $step из $totalSteps'), + ), + ], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween( + begin: 1 / totalSteps, + end: progressValue, + ), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation( + appTheme.blueA400, ), ), ), ), ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / 6; - }); - }, - children: const [ - CertificatesAndDiplomasScreen(), - PhotoOrScansScreen(), - AdditionalSettingsScreen(), - SocialNetworksScreen(), - RegistrationAsScreen(), - LastStepScreen(), - ], - ), - ); + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); }, ), ); diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart new file mode 100644 index 00000000..5e34fa79 --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart @@ -0,0 +1,28 @@ +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; + +/// Wraps a form field with [ValidationErrorWidget] when [error] is present. +/// Pass [hasError] into [CustomTextFormField] from the parent. +class PassportFieldWithError extends StatelessWidget { + const PassportFieldWithError({required this.child, this.error, super.key}); + + final Widget child; + final String? error; + + @override + Widget build(BuildContext context) { + final message = error?.trim(); + final showError = message != null && message.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + if (showError) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: message), + ], + ], + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart new file mode 100644 index 00000000..29abf8af --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart @@ -0,0 +1,34 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class PassportSectionTitle extends StatelessWidget { + const PassportSectionTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: TextStyles.textLSemibold.copyWith(color: appTheme.black), + ), + ); + } +} + +class PassportScreenTitle extends StatelessWidget { + const PassportScreenTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: TextStyles.textFS24FW700.copyWith(color: appTheme.black), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart new file mode 100644 index 00000000..764c803d --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart @@ -0,0 +1,218 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ScoringStatusWidget extends StatelessWidget { + const ScoringStatusWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.alfaScoringStatus != current.alfaScoringStatus || + previous.isScoringPolling != current.isScoringPolling || + previous.scoringRetryInProgress != current.scoringRetryInProgress || + previous.awaitingScoringOutcome != current.awaitingScoringOutcome, + builder: (context, state) { + final isWaiting = + state.awaitingScoringOutcome || state.isScoringPolling; + final status = state.alfaScoringStatus; + final showProcessing = status.isProcessing || + (isWaiting && !status.isTerminal); + + if (status == AlfaScoringStatus.none && !isWaiting) { + return const SizedBox.shrink(); + } + + final displayStatus = showProcessing && !status.isProcessing + ? AlfaScoringStatus.beneficiaryCreating + : status; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: appTheme.greyD9), + color: appTheme.whiteA700, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScoringStatusHeader(status: displayStatus), + const SizedBox(height: 12), + if (showProcessing) const ScoringProcessingBody(), + if (status.isComplete) const ScoringCompleteBody(), + if (status.isFailed) + ScoringFailedBody( + retryInProgress: state.scoringRetryInProgress, + onRetry: () => context + .read() + .retryScoring(), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class ScoringStatusHeader extends StatelessWidget { + const ScoringStatusHeader({required this.status, super.key}); + + final AlfaScoringStatus status; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + 'Проверка данных', + style: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ), + ), + Text( + ScoringStatusLabels.hintFor(status), + style: TextStyles.textSMedium.copyWith( + color: ScoringStatusLabels.colorFor(status), + ), + ), + ], + ); + } +} + +class ScoringProcessingBody extends StatelessWidget { + const ScoringProcessingBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.blueA400, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Идёт проверка…', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringCompleteBody extends StatelessWidget { + const ScoringCompleteBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.check_circle, color: appTheme.green, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Проверка успешно завершена', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringFailedBody extends StatelessWidget { + const ScoringFailedBody({ + required this.retryInProgress, + required this.onRetry, + super.key, + }); + + final bool retryInProgress; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ValidationErrorWidget(error: 'Не удалось пройти проверку'), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: retryInProgress ? null : onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: appTheme.blueA400, + disabledBackgroundColor: appTheme.greyD9, + foregroundColor: appTheme.whiteA700, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: retryInProgress + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.whiteA700, + ), + ) + : Text( + 'Повторить', + style: TextStyles.textMSemibold.copyWith( + color: appTheme.whiteA700, + ), + ), + ), + ), + ], + ); + } +} + +abstract final class ScoringStatusLabels { + static String hintFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.beneficiaryCreating => 'Создание профиля', + AlfaScoringStatus.scoringPending => 'Ожидание результата', + AlfaScoringStatus.scoringComplete => 'Успешно', + AlfaScoringStatus.beneficiaryFailed => 'Ошибка', + AlfaScoringStatus.scoringFailed => 'Ошибка', + AlfaScoringStatus.none => '', + }; + } + + static Color colorFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.scoringComplete => appTheme.green, + AlfaScoringStatus.beneficiaryFailed || + AlfaScoringStatus.scoringFailed => appTheme.red, + AlfaScoringStatus.beneficiaryCreating || + AlfaScoringStatus.scoringPending => appTheme.blueA400, + AlfaScoringStatus.none => appTheme.grey7B, + }; + } +} diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index c0e1c57a..4bc97dce 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:http_parser/http_parser.dart'; @@ -11,36 +12,53 @@ class ConsultantRepository { const ConsultantRepository({required this.consultantService}); - Future registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async { + Future registerConsultant({ + required ConsultantProfileModel consultant, + bool update = false, + }) async { if (update) { - return await consultantService.consultantUpdate(consultantModel: consultant); + return await consultantService.consultantUpdate( + consultantModel: consultant, + ); } else { - return await consultantService.consultantRegistration(consultantModel: consultant); + return await consultantService.consultantRegistration( + consultantModel: consultant, + ); } - } - Future getConsultantRequestStatus(String slug) async { + /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. + Future getConsultantRequestStatus( + String slug, + ) async { return await consultantService.getConsultantRequestStatus(slug: slug); - } + /// Своя заявка — education + legal/passport для регистрации и update. Future getMyRequestStatus(String slug) async { return await consultantService.getMyRequestStatus(slug: slug); + } + Future> getSbpBanks() async { + return await consultantService.getSbpBanks(); } Future> getConsultantCertPhotos() async { return await consultantService.getConsultantCertPhotos(); - } - Future> storeConsultantCertPhotos({required List files}) async { + Future> storeConsultantCertPhotos({ + required List files, + }) async { final multipartFiles = []; for (final file in files) { final fileBytes = await file.readAsBytes(); - final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream')); + final multipartFile = MultipartFile.fromBytes( + fileBytes, + filename: file.path.split('/').last, + contentType: MediaType('application', 'octet-stream'), + ); multipartFiles.add(multipartFile); } @@ -48,22 +66,24 @@ class ConsultantRepository { return []; } - return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); - + return await consultantService.storeConsultantCertPhotos( + photos: multipartFiles, + ); } Future hideConsultantCertPhoto(int id) async { - await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); - + await consultantService.hideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future unHideConsultantCertPhoto(int id) async { - await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); - + await consultantService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future deletePhoto(int id) async { await consultantService.deletePhoto(id: id); - } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 9a0757ad..8458824d 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:retrofit/retrofit.dart'; part 'consultant_service.g.dart'; @@ -10,21 +11,39 @@ part 'consultant_service.g.dart'; abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; - @POST('/api/consultant_request/registration') - Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); + @POST('/api/v2/consultant_request/registration') + Future consultantRegistration({ + @Body() required ConsultantProfileModel consultantModel, + }); @POST('/api/consultant_request/photos/upload') @MultiPart() - Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + Future> uploadPhotos({ + @Part(name: 'photos[]', contentType: 'multipart/form-data') + List? photos, + }); + /// Публичный статус консультанта для просмотра профиля. + /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') - Future getConsultantRequestStatus({@Path() required String slug}); + Future getConsultantRequestStatus({ + @Path() required String slug, + }); - @GET('/api/consultant_request/{slug}/status') - Future getMyRequestStatus({@Path() required String slug}); + /// Свой статус заявки для редактирования/модерации. + /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. + @GET('/api/v2/consultant_request/{slug}/status') + Future getMyRequestStatus({ + @Path() required String slug, + }); - @PUT('/api/consultant_request/update') - Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + @PUT('/api/v2/consultant_request/update') + Future consultantUpdate({ + @Body() required ConsultantProfileModel consultantModel, + }); + + @GET('/api/finances/sbp_banks') + Future> getSbpBanks(); @GET('/api/v2/consultant_request/cert_photos') Future> getConsultantCertPhotos(); @@ -32,14 +51,19 @@ abstract class ConsultantService { @POST('/api/v2/consultant_request/cert_photos/store') @MultiPart() Future> storeConsultantCertPhotos({ - @Part(name: 'photos[]', contentType: 'multipart/form-data') required List photos, + @Part(name: 'photos[]', contentType: 'multipart/form-data') + required List photos, }); @POST('/api/v2/consultant_request/cert_photos/hide') - Future hideConsultantCertPhoto({@Body() required Map payload}); + Future hideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/un_hide') - Future unHideConsultantCertPhoto({@Body() required Map payload}); + Future unHideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') Future deletePhoto({@Path() required int id}); diff --git a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart new file mode 100644 index 00000000..d4b0d947 --- /dev/null +++ b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart @@ -0,0 +1,167 @@ +import 'package:dio/dio.dart'; + +/// Parsed Laravel 422 validation payload for consultant registration UI. +class LaravelValidationErrors { + const LaravelValidationErrors({ + required this.summary, + this.fieldErrors = const {}, + }); + + final String summary; + final Map fieldErrors; + + static const LaravelValidationErrors empty = LaravelValidationErrors( + summary: '', + ); + + bool get hasFieldErrors => fieldErrors.isNotEmpty; +} + +const _genericValidationSummary = 'Проверьте введённые данные'; +const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; + +/// Parses Laravel `errors` map and maps them to user-facing Russian texts. +/// Raw backend messages are never shown in the UI. +LaravelValidationErrors parseLaravelValidationErrors(Object error) { + if (error is! DioException) { + return const LaravelValidationErrors(summary: _genericNetworkSummary); + } + + final data = error.response?.data; + if (error.response?.statusCode == 422 && data is Map) { + final fieldErrors = {}; + final errors = data['errors']; + + if (errors is Map) { + errors.forEach((key, value) { + final fieldKey = key.toString(); + final raw = _rawErrorText(value); + fieldErrors[fieldKey] = mapConsultantValidationMessage( + fieldKey: fieldKey, + rawMessage: raw, + ); + }); + } + + if (fieldErrors.isNotEmpty) { + final uniqueMessages = fieldErrors.values.toSet().toList(); + return LaravelValidationErrors( + summary: uniqueMessages.join('\n'), + fieldErrors: fieldErrors, + ); + } + + return const LaravelValidationErrors(summary: _genericValidationSummary); + } + + return const LaravelValidationErrors(summary: _genericNetworkSummary); +} + +String _rawErrorText(Object? value) { + if (value is List && value.isNotEmpty) { + return value.map((e) => e.toString()).join(' '); + } + if (value is String) { + return value; + } + return ''; +} + +/// Maps a backend validation message for [fieldKey] to a short Russian UI string. +String mapConsultantValidationMessage({ + required String fieldKey, + required String rawMessage, +}) { + final field = fieldKey.split('.').last.toLowerCase(); + final raw = rawMessage.toLowerCase(); + + final isUnique = + raw.contains('taken') || + raw.contains('unique') || + raw.contains('already') || + raw.contains('уже существ') || + raw.contains('уже занят'); + final isRequired = + raw.contains('required') || + raw.contains('обязательн') || + raw.contains('required_if'); + final isAccepted = + raw.contains('accepted') || field.contains('agreement'); + final isUrl = raw.contains('url') || field.contains('link'); + final isFormat = + raw.contains('format') || + raw.contains('invalid') || + raw.contains('regex') || + raw.contains('digits') || + raw.contains('size') || + raw.contains('date') || + raw.contains('before'); + + if (isAccepted) { + if (field.contains('frdo')) { + return 'Подтвердите согласие ФРДО'; + } + return 'Примите условия оферты'; + } + + if (isUnique) { + return switch (field) { + 'inn' => 'Такой ИНН существует', + 'snils' => 'Такой СНИЛС существует', + 'passport_number' || 'passport_series' => 'Такой паспорт существует', + 'sbp_phone' => 'Такой телефон СБП существует', + _ => 'Такие данные уже существуют', + }; + } + + if (isRequired) { + return switch (field) { + 'inn' => 'Укажите ИНН', + 'snils' => 'Укажите СНИЛС', + 'last_name' => 'Укажите фамилию', + 'first_name' => 'Укажите имя', + 'middle_name' => 'Укажите отчество', + 'passport_series' => 'Укажите серию паспорта', + 'passport_number' => 'Укажите номер паспорта', + 'passport_issue_date' => 'Укажите дату выдачи паспорта', + 'passport_issuer' => 'Укажите, кем выдан паспорт', + 'passport_issuer_code' => 'Укажите код подразделения', + 'birth_date' => 'Укажите дату рождения', + 'birth_place' => 'Укажите место рождения', + 'registration_address' => 'Укажите адрес регистрации', + 'residence_address' => 'Укажите адрес проживания', + 'citizenship' => 'Укажите гражданство', + 'sbp_phone' => 'Укажите телефон СБП', + 'sbp_bank_member_id' => 'Выберите банк СБП', + 'completed_courses' => 'Добавьте хотя бы один курс', + 'title' => 'Укажите название курса', + 'date' => 'Укажите дату курса', + 'agreement_accepted' => 'Примите условия оферты', + 'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО', + _ => 'Заполните поле', + }; + } + + if (isUrl) { + return 'Некорректная ссылка'; + } + + if (isFormat || raw.isNotEmpty) { + return switch (field) { + 'inn' => 'Некорректный ИНН', + 'snils' => 'Некорректный СНИЛС', + 'passport_series' => 'Некорректная серия паспорта', + 'passport_number' => 'Некорректный номер паспорта', + 'passport_issue_date' => 'Некорректная дата выдачи', + 'passport_issuer_code' => 'Некорректный код подразделения', + 'birth_date' => 'Некорректная дата рождения', + 'citizenship' => 'Некорректный код гражданства', + 'sbp_phone' => 'Некорректный телефон СБП', + 'title' => 'Слишком короткое название курса', + 'date' => 'Некорректная дата курса', + _ => 'Некорректное значение', + }; + } + + return 'Проверьте поле'; +} diff --git a/lib/features/feed/models/feed_blog_post_model.dart b/lib/features/feed/models/feed_blog_post_model.dart index 89eb5838..9ce22b7f 100644 --- a/lib/features/feed/models/feed_blog_post_model.dart +++ b/lib/features/feed/models/feed_blog_post_model.dart @@ -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 images, + @JsonKey(name: "video_url_embed") String? videoEmbedUrl, + @JsonKey(name: "video_url_thumb") String? videoUrlThumb, }) = _FeedBlogPostModel; factory FeedBlogPostModel.fromJson(Map json) => _$FeedBlogPostModelFromJson(json); diff --git a/lib/features/feed/models/post_feed_mapper.dart b/lib/features/feed/models/post_feed_mapper.dart new file mode 100644 index 00000000..f88e01ed --- /dev/null +++ b/lib/features/feed/models/post_feed_mapper.dart @@ -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().baseUrl}$url'; + return '${sl().baseUrl}/storage/$url'; + } +} diff --git a/lib/features/feed/models/post_feed_pagination_model.dart b/lib/features/feed/models/post_feed_pagination_model.dart new file mode 100644 index 00000000..31ade2a8 --- /dev/null +++ b/lib/features/feed/models/post_feed_pagination_model.dart @@ -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 data, + required CursorLinks links, + required CursorMeta meta, + }) = _PostFeedPaginationModel; + + factory PostFeedPaginationModel.fromJson(Map 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 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 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 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 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 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 json) => _$CursorMetaFromJson(json); +} + +DateTime parseDateTime(String date) => DateTime.parse(date).toLocal(); diff --git a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart index 9254bf07..a88d4623 100644 --- a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart +++ b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart @@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class FeedCubit extends Cubit { 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 { } } - Future loadFeedBlogPostList({String? query}) async { + Future 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 { } } - Future loadNextPage() async { - if (currentPage < lastPage && !isLoading && !isClosed) { - isLoading = true; + Future 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; } } diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 5eb4f454..58664992 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -83,7 +83,7 @@ class _FeedScreenContentState extends State { final state = feedCubit.state; // Загружаем данные, если они еще не загружены if (state is! FeedStateLoaded) { - feedCubit.loadFeedBlogPostList(); + feedCubit.loadFeedBlogPostList(currentUserId: context.read().state.profile?.id); } }); super.initState(); @@ -201,7 +201,10 @@ class _FeedScreenContentState extends State { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { switch (_selectedChipIndex) { case 0: - context.read().loadFeedBlogPostList(query: query); + context.read().loadFeedBlogPostList( + query: query, + currentUserId: context.read().state.profile?.id, + ); break; case 1 when !hidePlans: context.read().loadPlans(query: query); @@ -224,7 +227,9 @@ class _FeedScreenContentState extends State { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { switch (_selectedChipIndex) { case 0: - context.read().loadFeedBlogPostList(); + context.read().loadFeedBlogPostList( + currentUserId: context.read().state.profile?.id, + ); break; case 1 when !hidePlans: context.read().loadPlans(); diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart new file mode 100644 index 00000000..a0d8ddc8 --- /dev/null +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -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 galleryImages; + final String imageUrl; + final String? videoEmbedUrl; + final String? videoUrlThumb; + final int currentGalleryIndex; + final void Function(List urls, int index) onOpenGallery; + final ValueChanged onGalleryChanged; + + @override + State createState() => _BlogPostMediaContentState(); +} + +class _BlogPostMediaContentState extends State { + 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 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 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( + 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 4192aaff..3977d462 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -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 createState() => _BlogPostWidgetState(); @@ -30,243 +31,267 @@ class BlogPostWidget extends StatefulWidget { class _BlogPostWidgetState extends State { 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 imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } + + Future _toggleLike() async { + final nextLiked = !isLiked; + setState(() { + isLiked = nextLiked; + nextLiked ? totalLikes++ : totalLikes--; + }); + context.read().updateFeedBlogPostList(post, nextLiked); + if (nextLiked) { + await context.read().likePost(post.id); + } else { + await context.read().unlikedPost(post.id); + } + } @override Widget build(BuildContext context) { - return BlocBuilder( - 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().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().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0), - ); - }, - child: Hero( - tag: '${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}', - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: '${sl().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().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked); - // Fire network call without introducing UI delay - if (isLiked) { - await context.read().likePost(state.feedBlogPostList[widget.index].id); - } else { - await context.read().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().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 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().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); +} diff --git a/lib/features/feed/presentation/widgets/blog_posts.dart b/lib/features/feed/presentation/widgets/blog_posts.dart index b9da2ecc..afae31c7 100644 --- a/lib/features/feed/presentation/widgets/blog_posts.dart +++ b/lib/features/feed/presentation/widgets/blog_posts.dart @@ -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().loadFeedBlogPostList(), + onRefresh: () => context.read().loadFeedBlogPostList(currentUserId: context.read().state.profile?.id), child: BlocBuilder( builder: (BuildContext context, FeedState state) { if (state is FeedStateLoaded) { return NotificationListener( onNotification: (notification) { - loadNewContent(notification, context.read().loadNextPage); + loadNewContent(notification, () => context.read().loadNextPage(currentUserId: context.read().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().loadFeedBlogPostList()); + return DataErrorWidget( + onReload: () => context.read().loadFeedBlogPostList(currentUserId: context.read().state.profile?.id), + ); } else { return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader()); } diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart new file mode 100644 index 00000000..056d0e37 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart @@ -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.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 ? '' : ''; + final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk); + + return ''' + + + + + + + $headScripts + + + + + + +'''; + } +} diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart new file mode 100644 index 00000000..5d661bde --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart @@ -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 `