forked from eatvio/eatvio_app
Compare commits
5 Commits
personaliz
...
consultant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0cab89126 | ||
|
|
954d4955da | ||
| 0d93768dc3 | |||
|
|
8c5fc8d827 | ||
|
|
037f153273 |
@@ -1,7 +1,7 @@
|
||||
# Current Context
|
||||
|
||||
## Project Status
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
||||
|
||||
## Recent Activity
|
||||
- Memory Bank initialization completed
|
||||
@@ -9,19 +9,10 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
||||
- Architecture and technology stack catalogued
|
||||
|
||||
## Current Focus
|
||||
Виджет видеоплеера ленты (`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`.
|
||||
Ready for development tasks. No active work in progress.
|
||||
|
||||
## Known Issues
|
||||
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||
связаны с fixtures/маппингом, не с видеоплеером.
|
||||
None documented at initialization.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
|
||||
@@ -47,6 +47,8 @@ post_install do |installer|
|
||||
## dart: PermissionGroup.photos
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_NOTIFICATIONS=1',
|
||||
## dart: PermissionGroup.mediaLibrary
|
||||
'PERMISSION_MEDIA_LIBRARY=1',
|
||||
]
|
||||
|
||||
end
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -265,7 +265,15 @@ class OtherRoutes {
|
||||
),
|
||||
|
||||
// Consultant routes
|
||||
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.becomeConsultantScreen,
|
||||
name: 'becomeConsultantScreen',
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, dynamic>?;
|
||||
final slug = data?['slug'] as String?;
|
||||
return BecomeConsultantScreen(slug: slug);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||
name: 'stepperBecomeConsultantScreen',
|
||||
@@ -273,7 +281,12 @@ class OtherRoutes {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final slug = data['slug'] as String?;
|
||||
final isCanceled = data['isCanceled'] as bool?;
|
||||
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
|
||||
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
|
||||
return StepperBecomeConsultantScreen(
|
||||
slug: slug,
|
||||
isCanceled: isCanceled,
|
||||
isNewRegistration: isNewRegistration,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:cached_network_image/cached_network_image.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,10 +9,7 @@ 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';
|
||||
@@ -36,7 +33,6 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||
final textEditingController = TextEditingController();
|
||||
int? replyId;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -44,10 +40,6 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
@@ -250,29 +242,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
|
||||
|
||||
const ConsultantCertPhotoModel._();
|
||||
|
||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
|
||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ConsultantCertPhotoModelFromJson(json);
|
||||
|
||||
static const String visibleGroup = 'user_certs';
|
||||
static const String hiddenGroup = 'hidden_user_certs';
|
||||
|
||||
@@ -6,27 +6,100 @@ part 'consultant_profile_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
const ConsultantProfileModel._();
|
||||
|
||||
const factory ConsultantProfileModel({
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false)
|
||||
bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false)
|
||||
SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false)
|
||||
List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
|
||||
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
|
||||
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
|
||||
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
|
||||
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
|
||||
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
|
||||
@JsonKey(name: 'passport_series', includeIfNull: false)
|
||||
String? passportSeries,
|
||||
@JsonKey(name: 'passport_number', includeIfNull: false)
|
||||
String? passportNumber,
|
||||
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
|
||||
String? passportIssueDate,
|
||||
@JsonKey(name: 'passport_issuer', includeIfNull: false)
|
||||
String? passportIssuer,
|
||||
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
|
||||
String? passportIssuerCode,
|
||||
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
|
||||
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
|
||||
@JsonKey(name: 'registration_address', includeIfNull: false)
|
||||
String? registrationAddress,
|
||||
@JsonKey(name: 'residence_address', includeIfNull: false)
|
||||
String? residenceAddress,
|
||||
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
|
||||
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
|
||||
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
|
||||
bool? foreignTaxResident,
|
||||
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
|
||||
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
|
||||
String? sbpBankMemberId,
|
||||
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
|
||||
bool? agreementAccepted,
|
||||
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
|
||||
bool? frdoAgreementAccepted,
|
||||
}) = _ConsultantProfileModel;
|
||||
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
final last = lastName ?? '';
|
||||
final first = firstName ?? '';
|
||||
final innValue = inn ?? '';
|
||||
final snilsValue = snils ?? '';
|
||||
final series = passportSeries ?? '';
|
||||
final number = passportNumber ?? '';
|
||||
final issueDate = passportIssueDate ?? '';
|
||||
final issuer = passportIssuer ?? '';
|
||||
final issuerCode = passportIssuerCode ?? '';
|
||||
final birth = birthDate ?? '';
|
||||
final birthPlaceValue = birthPlace ?? '';
|
||||
final registration = registrationAddress ?? '';
|
||||
final residence = residenceAddress ?? '';
|
||||
final citizenshipValue = citizenship ?? '';
|
||||
|
||||
return last.trim().isNotEmpty &&
|
||||
first.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
||||
issuer.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
||||
birthPlaceValue.trim().isNotEmpty &&
|
||||
registration.trim().isNotEmpty &&
|
||||
residence.trim().isNotEmpty &&
|
||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||
const factory SocialLinksModel({
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false)
|
||||
String? personalSiteLink,
|
||||
}) = _SocialLinksModel;
|
||||
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
|
||||
_$SocialLinksModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -36,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel {
|
||||
required String date,
|
||||
}) = _CertificatesModel;
|
||||
|
||||
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
|
||||
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
|
||||
_$CertificatesModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
||||
@JsonKey(name: 'rating_place') int? ratingPlace,
|
||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
||||
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
|
||||
@JsonKey(name: 'canceled_moderation')
|
||||
CanceledModerationModel? canceledModeration,
|
||||
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
|
||||
}) = _ConsultationRequestModel;
|
||||
|
||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
|
||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ConsultationRequestModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LegalEntityModel with _$LegalEntityModel {
|
||||
const LegalEntityModel._();
|
||||
|
||||
const factory LegalEntityModel({
|
||||
required int id,
|
||||
required String type,
|
||||
String? inn,
|
||||
String? snils,
|
||||
String? state,
|
||||
@JsonKey(
|
||||
name: 'alfa_scoring_status',
|
||||
unknownEnumValue: AlfaScoringStatus.none,
|
||||
defaultValue: AlfaScoringStatus.none,
|
||||
)
|
||||
@Default(AlfaScoringStatus.none)
|
||||
AlfaScoringStatus alfaScoringStatus,
|
||||
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
|
||||
@JsonKey(name: 'last_name') String? lastName,
|
||||
@JsonKey(name: 'first_name') String? firstName,
|
||||
@JsonKey(name: 'middle_name') String? middleName,
|
||||
@JsonKey(name: 'passport_series') String? passportSeries,
|
||||
@JsonKey(name: 'passport_number') String? passportNumber,
|
||||
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
|
||||
@JsonKey(name: 'passport_issuer') String? passportIssuer,
|
||||
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
|
||||
@JsonKey(name: 'birth_date') String? birthDate,
|
||||
@JsonKey(name: 'birth_place') String? birthPlace,
|
||||
@JsonKey(name: 'registration_address') String? registrationAddress,
|
||||
@JsonKey(name: 'residence_address') String? residenceAddress,
|
||||
String? citizenship,
|
||||
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
|
||||
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
|
||||
@JsonKey(name: 'sbp_phone') String? sbpPhone,
|
||||
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
|
||||
}) = _LegalEntityModel;
|
||||
|
||||
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$LegalEntityModelFromJson(json);
|
||||
|
||||
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
|
||||
static String? normalizeApiDate(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
|
||||
return value.substring(0, 10);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String? get normalizedPassportIssueDate =>
|
||||
normalizeApiDate(passportIssueDate);
|
||||
|
||||
String? get normalizedBirthDate => normalizeApiDate(birthDate);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||
const factory CertPhotosModel({
|
||||
required String url,
|
||||
}) = _CertPhotosModel;
|
||||
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
|
||||
|
||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
|
||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$CertPhotosModelFromJson(json);
|
||||
}
|
||||
|
||||
enum ModerationStatus {
|
||||
approved,
|
||||
created,
|
||||
canceled
|
||||
}
|
||||
enum ModerationStatus { approved, created, canceled }
|
||||
|
||||
@freezed
|
||||
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||
required List<String>? additional,
|
||||
}) = _CanceledModerationModel;
|
||||
|
||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
|
||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$CanceledModerationModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'sbp_bank_model.freezed.dart';
|
||||
part 'sbp_bank_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class SbpBankModel with _$SbpBankModel {
|
||||
const factory SbpBankModel({
|
||||
@JsonKey(name: 'bank_member_id') required String bankMemberId,
|
||||
@JsonKey(name: 'bank_name') required String bankName,
|
||||
@JsonKey(name: 'short_name') String? shortName,
|
||||
@JsonKey(name: 'logo_url') String? logoUrl,
|
||||
String? bic,
|
||||
}) = _SbpBankModel;
|
||||
|
||||
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$SbpBankModelFromJson(json);
|
||||
}
|
||||
@@ -1,42 +1,117 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
final ConsultantRepository consultantRepository;
|
||||
final Duration scoringPollInterval;
|
||||
bool _update = false;
|
||||
String? _userSlug;
|
||||
Timer? _scoringPollTimer;
|
||||
|
||||
RegisterConsultantCubit({required this.consultantRepository})
|
||||
: super(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
||||
loading: true,
|
||||
acceptOffer: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
static const String individualPersonLegalType = 'individualPerson';
|
||||
|
||||
RegisterConsultantCubit({
|
||||
required this.consultantRepository,
|
||||
this.scoringPollInterval = const Duration(seconds: 3),
|
||||
}) : super(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(
|
||||
socialLinks: SocialLinksModel(),
|
||||
hasMedicalEducation: true,
|
||||
legalType: individualPersonLegalType,
|
||||
),
|
||||
loading: true,
|
||||
acceptOffer: false,
|
||||
acceptFrdoAgreement: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
|
||||
String? get userSlug => _userSlug;
|
||||
|
||||
void setUserSlug(String? slug) {
|
||||
if (slug != null && slug.isNotEmpty) {
|
||||
_userSlug = slug;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SbpBankModel>> _loadSbpBanks() async {
|
||||
try {
|
||||
return await consultantRepository.getSbpBanks();
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
return const <SbpBankModel>[];
|
||||
}
|
||||
}
|
||||
|
||||
List<SbpBankModel> searchSbpBanks(String query) {
|
||||
final normalized = query.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return state.sbpBanks;
|
||||
}
|
||||
return state.sbpBanks.where((bank) {
|
||||
final name = bank.bankName.toLowerCase();
|
||||
final shortName = (bank.shortName ?? '').toLowerCase();
|
||||
final bic = (bank.bic ?? '').toLowerCase();
|
||||
return name.contains(normalized) ||
|
||||
shortName.contains(normalized) ||
|
||||
bic.contains(normalized);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
SbpBankModel? findSbpBankById(String? bankMemberId) {
|
||||
if (bankMemberId == null || bankMemberId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final bank in state.sbpBanks) {
|
||||
if (bank.bankMemberId == bankMemberId) {
|
||||
return bank;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getMyRequestStatus(String slug) async {
|
||||
try {
|
||||
_update = true;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
||||
_userSlug = slug;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(
|
||||
slug,
|
||||
);
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
|
||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
List<CertificatesModel>? certificates = myRequestStatus
|
||||
.educationModel
|
||||
.completedCourses
|
||||
?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
|
||||
return CertificatesModel(title: course.title, date: course.date);
|
||||
}).toList();
|
||||
return CertificatesModel(title: course.title, date: course.date);
|
||||
})
|
||||
.toList();
|
||||
|
||||
String? sanitizeLink(String? link) {
|
||||
return (link != null && link.isNotEmpty) ? link : null;
|
||||
}
|
||||
|
||||
final legal = myRequestStatus.legalEntity;
|
||||
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||
|
||||
emit(
|
||||
RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(
|
||||
@@ -46,33 +121,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
||||
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
||||
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
||||
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
|
||||
personalSiteLink: sanitizeLink(
|
||||
myRequestStatus.educationModel.personalSiteLink,
|
||||
),
|
||||
),
|
||||
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||
hasMedicalEducation:
|
||||
myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||
legalType: legal?.type ?? individualPersonLegalType,
|
||||
lastName: _emptyToNull(legal?.lastName),
|
||||
firstName: _emptyToNull(legal?.firstName),
|
||||
middleName: _emptyToNull(legal?.middleName),
|
||||
inn: _emptyToNull(legal?.inn),
|
||||
snils: _emptyToNull(legal?.snils),
|
||||
passportSeries: _emptyToNull(legal?.passportSeries),
|
||||
passportNumber: _emptyToNull(legal?.passportNumber),
|
||||
passportIssueDate: legal?.normalizedPassportIssueDate,
|
||||
passportIssuer: _emptyToNull(legal?.passportIssuer),
|
||||
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
|
||||
birthDate: legal?.normalizedBirthDate,
|
||||
birthPlace: _emptyToNull(legal?.birthPlace),
|
||||
registrationAddress: _emptyToNull(legal?.registrationAddress),
|
||||
residenceAddress: _emptyToNull(legal?.residenceAddress),
|
||||
citizenship: _emptyToNull(legal?.citizenship),
|
||||
taxResidentUs: legal?.taxResidentUs ?? false,
|
||||
foreignTaxResident: legal?.foreignTaxResident ?? false,
|
||||
sbpPhone: _emptyToNull(legal?.sbpPhone),
|
||||
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
|
||||
),
|
||||
acceptOffer: false,
|
||||
acceptFrdoAgreement: false,
|
||||
loading: false,
|
||||
requestModel: myRequestStatus,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
isUpdating: _update,
|
||||
alfaScoringStatus: scoringStatus,
|
||||
isScoringPolling: scoringStatus.isProcessing,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
fieldErrors: const <String, String>{},
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
|
||||
if (scoringStatus.isProcessing) {
|
||||
startScoringPoll();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
awaitingScoringOutcome: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _emptyToNull(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
Future<void> disableLoading({String? slug}) async {
|
||||
try {
|
||||
setUserSlug(slug);
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
awaitingScoringOutcome: false,
|
||||
consultant: state.consultant.copyWith(
|
||||
legalType: individualPersonLegalType,
|
||||
citizenship: state.consultant.citizenship,
|
||||
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(loading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableLoading() async {
|
||||
try {
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(loading: false, error: e.toString()));
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
awaitingScoringOutcome: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,41 +231,325 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (slug != null) {
|
||||
await getMyRequestStatus(slug);
|
||||
} else {
|
||||
await disableLoading();
|
||||
await disableLoading(slug: _userSlug);
|
||||
}
|
||||
}
|
||||
|
||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
||||
void updateConsultantProfileModel(
|
||||
ConsultantProfileModel consultant, [
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasAcceptedRequiredAgreements {
|
||||
if (_update) {
|
||||
return state.acceptOffer;
|
||||
}
|
||||
return state.acceptOffer && state.acceptFrdoAgreement;
|
||||
}
|
||||
|
||||
ConsultantProfileModel buildSubmitPayload() {
|
||||
final withLegalType = state.consultant.copyWith(
|
||||
legalType: individualPersonLegalType,
|
||||
citizenship: state.consultant.citizenship,
|
||||
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||
certificates: state.consultant.certificates
|
||||
?.map(
|
||||
(course) => CertificatesModel(
|
||||
title: course.title,
|
||||
date:
|
||||
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (_update) {
|
||||
return ConsultantProfileModel(
|
||||
hasMedicalEducation: withLegalType.hasMedicalEducation,
|
||||
socialLinks: withLegalType.socialLinks,
|
||||
photos: withLegalType.photos,
|
||||
certificates: withLegalType.certificates,
|
||||
legalType: withLegalType.legalType,
|
||||
lastName: withLegalType.lastName,
|
||||
firstName: withLegalType.firstName,
|
||||
middleName: withLegalType.middleName,
|
||||
inn: withLegalType.inn,
|
||||
snils: withLegalType.snils,
|
||||
passportSeries: withLegalType.passportSeries,
|
||||
passportNumber: withLegalType.passportNumber,
|
||||
passportIssueDate: withLegalType.passportIssueDate,
|
||||
passportIssuer: withLegalType.passportIssuer,
|
||||
passportIssuerCode: withLegalType.passportIssuerCode,
|
||||
birthDate: withLegalType.birthDate,
|
||||
birthPlace: withLegalType.birthPlace,
|
||||
registrationAddress: withLegalType.registrationAddress,
|
||||
residenceAddress: withLegalType.residenceAddress,
|
||||
citizenship: withLegalType.citizenship,
|
||||
taxResidentUs: withLegalType.taxResidentUs,
|
||||
foreignTaxResident: withLegalType.foreignTaxResident,
|
||||
sbpPhone: withLegalType.sbpPhone,
|
||||
sbpBankMemberId: withLegalType.sbpBankMemberId,
|
||||
);
|
||||
}
|
||||
|
||||
return withLegalType.copyWith(
|
||||
agreementAccepted: state.acceptOffer,
|
||||
frdoAgreementAccepted: state.acceptFrdoAgreement,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
emit(state.copyWith(error: null));
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show scoring widget immediately while request / job are in progress.
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
awaitingScoringOutcome: true,
|
||||
scoringRetryInProgress: false,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
if (state.newAddedPhotos.isNotEmpty) {
|
||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
||||
await consultantRepository.storeConsultantCertPhotos(
|
||||
files: state.newAddedPhotos,
|
||||
);
|
||||
}
|
||||
if (state.deletedPhotoIds.isNotEmpty) {
|
||||
for (final int id in state.deletedPhotoIds) {
|
||||
await consultantRepository.deletePhoto(id);
|
||||
}
|
||||
}
|
||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: _update,
|
||||
);
|
||||
_update = true;
|
||||
emit(
|
||||
state.copyWith(
|
||||
isUpdating: true,
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: true,
|
||||
newAddedPhotos: const <File>[],
|
||||
deletedPhotoIds: const <int>[],
|
||||
fieldErrors: const <String, String>{},
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
startScoringPoll();
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
isScoringPolling: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.none,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: e.toString(),
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
isScoringPolling: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> retryScoring() async {
|
||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
awaitingScoringOutcome: true,
|
||||
),
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: 'Заполните паспортные данные',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: true,
|
||||
);
|
||||
startScoringPoll();
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isUpdating: true,
|
||||
awaitingScoringOutcome: true,
|
||||
),
|
||||
);
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void startScoringPoll() {
|
||||
final slug = _userSlug;
|
||||
if (slug == null || slug.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_stopScoringPoll();
|
||||
emit(state.copyWith(isScoringPolling: true));
|
||||
_scoringPollTimer = Timer.periodic(
|
||||
scoringPollInterval,
|
||||
(_) => _pollScoringStatus(slug),
|
||||
);
|
||||
unawaited(_pollScoringStatus(slug));
|
||||
}
|
||||
|
||||
Future<void> _pollScoringStatus(String slug) async {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final status = await consultantRepository.getMyRequestStatus(slug);
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
final scoringStatus =
|
||||
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||
// API stays on `none` until ProcessConsultantScoringJob starts
|
||||
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
|
||||
// UI and continue polling until a real terminal/processing status arrives.
|
||||
if (scoringStatus == AlfaScoringStatus.none) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
requestModel: status,
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final isTerminal = scoringStatus.isTerminal;
|
||||
emit(
|
||||
state.copyWith(
|
||||
requestModel: status,
|
||||
alfaScoringStatus: scoringStatus,
|
||||
isScoringPolling: scoringStatus.isProcessing,
|
||||
// Clear session flag on terminal so success sheet can fire once
|
||||
// (listenWhen: previous.awaiting && !current.awaiting && complete).
|
||||
awaitingScoringOutcome: isTerminal
|
||||
? false
|
||||
: state.awaitingScoringOutcome,
|
||||
),
|
||||
);
|
||||
if (!scoringStatus.isProcessing) {
|
||||
_stopScoringPoll();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
// Keep polling on transient errors while scoring is in progress.
|
||||
}
|
||||
}
|
||||
|
||||
void _stopScoringPoll() {
|
||||
_scoringPollTimer?.cancel();
|
||||
_scoringPollTimer = null;
|
||||
}
|
||||
|
||||
Future<void> addCertPhotos(List<File> files) async {
|
||||
if (files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
|
||||
final compressedFiles = await ImageCompressUtility.compressFilesList(
|
||||
files,
|
||||
);
|
||||
|
||||
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
|
||||
emit(
|
||||
state.copyWith(
|
||||
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
@@ -130,7 +563,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
return;
|
||||
}
|
||||
if (path != null) {
|
||||
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
newAddedPhotos: state.newAddedPhotos
|
||||
.where((file) => file.uri.path != path)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopScoringPoll();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'register_consultant_state.freezed.dart';
|
||||
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
||||
const factory RegisterConsultantState({
|
||||
required ConsultantProfileModel consultant,
|
||||
required bool acceptOffer,
|
||||
@Default(false) bool acceptFrdoAgreement,
|
||||
required bool loading,
|
||||
required bool isUpdating,
|
||||
ConsultationRequestModel? requestModel,
|
||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<ConsultantCertPhotoModel>[])
|
||||
List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||
@Default(<File>[]) List<File> newAddedPhotos,
|
||||
@Default(<int>[]) List<int> deletedPhotoIds,
|
||||
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
|
||||
@Default(false) bool isScoringPolling,
|
||||
@Default(false) bool scoringRetryInProgress,
|
||||
|
||||
/// True only after successful register/retry in this session until scoring is terminal.
|
||||
@Default(false) bool awaitingScoringOutcome,
|
||||
@Default(<String, String>{}) Map<String, String> fieldErrors,
|
||||
String? error,
|
||||
}) = _RegisterConsultantState;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
|
||||
const AdditionalSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
|
||||
State<AdditionalSettingsScreen> createState() =>
|
||||
_AdditionalSettingsScreenState();
|
||||
}
|
||||
|
||||
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
|
||||
if (context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.hasMedicalEducation !=
|
||||
null) {
|
||||
haveMedicalDegree = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
value: publishDiplomasAndCertificatesInProfile,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
|
||||
publishDiplomasAndCertificatesInProfile =
|
||||
!publishDiplomasAndCertificatesInProfile;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
setState(() {
|
||||
haveMedicalDegree = !haveMedicalDegree;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
hasMedicalEducation: haveMedicalDegree,
|
||||
),
|
||||
);
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.updateConsultantProfileModel(
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.copyWith(hasMedicalEducation: haveMedicalDegree),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class BecomeConsultantScreen extends StatefulWidget {
|
||||
const BecomeConsultantScreen({super.key});
|
||||
final String? slug;
|
||||
|
||||
const BecomeConsultantScreen({super.key, this.slug});
|
||||
|
||||
@override
|
||||
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
||||
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
title: const Text('Стать консультантом'),
|
||||
automaticallyImplyLeading: false,
|
||||
centerTitle: false,
|
||||
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
|
||||
leading: IconButton(
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
|
||||
InkWell _startRegistrationButton() {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
|
||||
onTap: () => GoRouter.of(context).pushReplacementNamed(
|
||||
'stepperBecomeConsultantScreen',
|
||||
extra: {'slug': widget.slug, 'isNewRegistration': true},
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Начать регистрацию',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||
const CertificatesAndDiplomasScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
|
||||
State<CertificatesAndDiplomasScreen> createState() =>
|
||||
_CertificatesAndDiplomasScreenState();
|
||||
}
|
||||
|
||||
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
|
||||
class _CertificatesAndDiplomasScreenState
|
||||
extends State<CertificatesAndDiplomasScreen> {
|
||||
final scrollController = ScrollController();
|
||||
|
||||
List<DiplomaEntity> diplomasList = [
|
||||
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
|
||||
null) {
|
||||
diplomasList = [
|
||||
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
|
||||
...context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.certificates!
|
||||
.map(
|
||||
(e) => DiplomaEntity(
|
||||
date: DateTime.tryParse(e.date),
|
||||
textEditingController: TextEditingController(text: e.title),
|
||||
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
|
||||
itemBuilder: (context, index) =>
|
||||
_diplomaInfo(diploma: diplomasList[index]),
|
||||
separatorBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: _divider(context),
|
||||
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
);
|
||||
}
|
||||
|
||||
Column _diplomaInfo({
|
||||
required DiplomaEntity diploma,
|
||||
}) {
|
||||
Column _diplomaInfo({required DiplomaEntity diploma}) {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
|
||||
width:
|
||||
MediaQuery.of(context).size.width -
|
||||
(diploma.enableDeleteButton ? 92 : 32),
|
||||
child: ListTile(
|
||||
onTap: onTapCalendar(context, diploma),
|
||||
trailing: Icon(
|
||||
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
title: Text(
|
||||
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
||||
style: TextStyle(
|
||||
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
|
||||
color: diploma.date == null
|
||||
? Colors.grey.shade400
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
tileColor: const Color(0xFFF6F6F6),
|
||||
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
),
|
||||
child: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
color: CupertinoColors.systemBackground.resolveFrom(context),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: child,
|
||||
),
|
||||
child: SafeArea(top: false, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void checkLast() {
|
||||
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
|
||||
if (diplomasList.last.textEditingController.text.isNotEmpty ||
|
||||
diplomasList.last.date != null) {
|
||||
setState(() {
|
||||
diplomasList.add(
|
||||
DiplomaEntity(
|
||||
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
|
||||
void _saveState() {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
||||
0,
|
||||
diplomasList.length - 1,
|
||||
),
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList
|
||||
.map(
|
||||
(e) => CertificatesModel(
|
||||
title: e.textEditingController.text,
|
||||
date: e.date == null
|
||||
? ''
|
||||
: DateFormat('yyyy-MM-dd').format(e.date!),
|
||||
),
|
||||
);
|
||||
)
|
||||
.toList()
|
||||
.sublist(0, diplomasList.length - 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LastStepScreenState extends State<LastStepScreen> {
|
||||
bool checkboxState = false;
|
||||
static const _frdoUrl =
|
||||
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
|
||||
|
||||
late final TapGestureRecognizer _frdoTapRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_frdoTapRecognizer = TapGestureRecognizer()
|
||||
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frdoTapRecognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.watch<RegisterConsultantCubit>();
|
||||
final state = cubit.state;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||
child: Column(
|
||||
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
child: Text(
|
||||
'Последний шаг',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.awaitingScoringOutcome ||
|
||||
state.isScoringPolling ||
|
||||
state.alfaScoringStatus !=
|
||||
AlfaScoringStatus.none) ...[
|
||||
const SizedBox(height: 16),
|
||||
const ScoringStatusWidget(),
|
||||
],
|
||||
if (state.requestModel?.canceledModeration != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33FFCC00),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.requestModel!.canceledModeration!.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
if (state.requestModel!.canceledModeration!.additional !=
|
||||
null) ...[
|
||||
const SizedBox(height: 8),
|
||||
for (final item
|
||||
in state
|
||||
.requestModel!
|
||||
.canceledModeration!
|
||||
.additional!) ...[
|
||||
Text(
|
||||
'• $item',
|
||||
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
value: checkboxState,
|
||||
value: state.acceptOffer,
|
||||
title: Text(
|
||||
hidePlans
|
||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
||||
style: const TextStyle(fontSize: 18, height: 1),
|
||||
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
checkboxState = !checkboxState;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (!state.isUpdating) ...[
|
||||
CheckboxListTile(
|
||||
value: state.acceptFrdoAgreement,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1.2,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text:
|
||||
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
|
||||
),
|
||||
TextSpan(
|
||||
text: 'ФРДО',
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: _frdoTapRecognizer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: _divider(context),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: TextButton(
|
||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
||||
onPressed: () async => await launchUrl(
|
||||
Uri.parse(
|
||||
hidePlans
|
||||
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
|
||||
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
||||
hidePlans
|
||||
? 'Политика конфиденциальности и пользовательское соглашение'
|
||||
: 'Договор оферты',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
fontSize: 16,
|
||||
color: Color(0xFF323F4B),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
return Container(
|
||||
height: 0.4,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
|
||||
class PassportDataScreen extends StatefulWidget {
|
||||
const PassportDataScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PassportDataScreen> createState() => _PassportDataScreenState();
|
||||
}
|
||||
|
||||
class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
late final TextEditingController lastNameController;
|
||||
late final TextEditingController firstNameController;
|
||||
late final TextEditingController middleNameController;
|
||||
late final TextEditingController innController;
|
||||
late final TextEditingController snilsController;
|
||||
late final TextEditingController seriesController;
|
||||
late final TextEditingController numberController;
|
||||
late final TextEditingController issueDateController;
|
||||
late final TextEditingController issuerController;
|
||||
late final TextEditingController issuerCodeController;
|
||||
late final TextEditingController birthDateController;
|
||||
late final TextEditingController birthPlaceController;
|
||||
late final TextEditingController citizenshipController;
|
||||
late final TextEditingController registrationAddressController;
|
||||
late final TextEditingController residenceAddressController;
|
||||
late final TextEditingController sbpPhoneController;
|
||||
late final TextEditingController sbpBankController;
|
||||
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
|
||||
|
||||
bool taxResidentUs = false;
|
||||
bool foreignTaxResident = false;
|
||||
String? selectedSbpBankMemberId;
|
||||
String? selectedSbpBankName;
|
||||
|
||||
final snilsMask = MaskTextInputFormatter(
|
||||
mask: '###-###-### ##',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final dateMaskIssue = MaskTextInputFormatter(
|
||||
mask: '##.##.####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final dateMaskBirth = MaskTextInputFormatter(
|
||||
mask: '##.##.####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final issuerCodeMask = MaskTextInputFormatter(
|
||||
mask: '###-###',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final phoneMask = MaskTextInputFormatter(
|
||||
mask: '+7 (###) ### ## ##',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
|
||||
lastNameController = TextEditingController(text: consultant.lastName ?? '');
|
||||
firstNameController = TextEditingController(
|
||||
text: consultant.firstName ?? '',
|
||||
);
|
||||
middleNameController = TextEditingController(
|
||||
text: consultant.middleName ?? '',
|
||||
);
|
||||
innController = TextEditingController(text: consultant.inn ?? '');
|
||||
snilsController = TextEditingController(text: consultant.snils ?? '');
|
||||
seriesController = TextEditingController(
|
||||
text: consultant.passportSeries ?? '',
|
||||
);
|
||||
numberController = TextEditingController(
|
||||
text: consultant.passportNumber ?? '',
|
||||
);
|
||||
issueDateController = TextEditingController(
|
||||
text: _apiDateToUi(consultant.passportIssueDate),
|
||||
);
|
||||
issuerController = TextEditingController(
|
||||
text: consultant.passportIssuer ?? '',
|
||||
);
|
||||
issuerCodeController = TextEditingController(
|
||||
text: consultant.passportIssuerCode ?? '',
|
||||
);
|
||||
birthDateController = TextEditingController(
|
||||
text: _apiDateToUi(consultant.birthDate),
|
||||
);
|
||||
birthPlaceController = TextEditingController(
|
||||
text: consultant.birthPlace ?? '',
|
||||
);
|
||||
citizenshipController = TextEditingController(text: consultant.citizenship);
|
||||
registrationAddressController = TextEditingController(
|
||||
text: consultant.registrationAddress ?? '',
|
||||
);
|
||||
residenceAddressController = TextEditingController(
|
||||
text: consultant.residenceAddress ?? '',
|
||||
);
|
||||
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
|
||||
selectedSbpBankMemberId = consultant.sbpBankMemberId;
|
||||
final selectedBank = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.findSbpBankById(consultant.sbpBankMemberId);
|
||||
selectedSbpBankName = selectedBank?.bankName;
|
||||
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
|
||||
taxResidentUs = consultant.taxResidentUs ?? false;
|
||||
foreignTaxResident = consultant.foreignTaxResident ?? false;
|
||||
}
|
||||
|
||||
void _hydrateSbpBankNameIfNeeded() {
|
||||
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (sbpBankController.text.trim().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
final selectedBank = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.findSbpBankById(selectedSbpBankMemberId);
|
||||
if (selectedBank == null) {
|
||||
return;
|
||||
}
|
||||
selectedSbpBankName = selectedBank.bankName;
|
||||
sbpBankController.text = selectedBank.bankName;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
lastNameController.dispose();
|
||||
firstNameController.dispose();
|
||||
middleNameController.dispose();
|
||||
innController.dispose();
|
||||
snilsController.dispose();
|
||||
seriesController.dispose();
|
||||
numberController.dispose();
|
||||
issueDateController.dispose();
|
||||
issuerController.dispose();
|
||||
issuerCodeController.dispose();
|
||||
birthDateController.dispose();
|
||||
birthPlaceController.dispose();
|
||||
citizenshipController.dispose();
|
||||
registrationAddressController.dispose();
|
||||
residenceAddressController.dispose();
|
||||
sbpPhoneController.dispose();
|
||||
sbpBankController.dispose();
|
||||
sbpBankSuggestionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _apiDateToUi(String? apiDate) {
|
||||
if (apiDate == null || apiDate.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final parts = apiDate.split('-');
|
||||
if (parts.length != 3) {
|
||||
return apiDate;
|
||||
}
|
||||
return '${parts[2]}.${parts[1]}.${parts[0]}';
|
||||
}
|
||||
|
||||
String? _uiDateToApi(String uiDate) {
|
||||
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length != 8) {
|
||||
return null;
|
||||
}
|
||||
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
|
||||
}
|
||||
|
||||
String? _uiPhoneToApi(String uiPhone) {
|
||||
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (digits.length == 11 && digits.startsWith('7')) {
|
||||
return '+$digits';
|
||||
}
|
||||
if (digits.length == 10) {
|
||||
return '+7$digits';
|
||||
}
|
||||
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
|
||||
}
|
||||
|
||||
void _syncToCubit() {
|
||||
final cubit = context.read<RegisterConsultantCubit>();
|
||||
final current = cubit.state.consultant;
|
||||
cubit.updateConsultantProfileModel(
|
||||
current.copyWith(
|
||||
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||
lastName: lastNameController.text.trim().isEmpty
|
||||
? null
|
||||
: lastNameController.text.trim(),
|
||||
firstName: firstNameController.text.trim().isEmpty
|
||||
? null
|
||||
: firstNameController.text.trim(),
|
||||
middleName: middleNameController.text.trim().isEmpty
|
||||
? null
|
||||
: middleNameController.text.trim(),
|
||||
inn: innController.text.trim().isEmpty
|
||||
? null
|
||||
: innController.text.trim(),
|
||||
snils: snilsController.text.trim().isEmpty
|
||||
? null
|
||||
: snilsController.text.trim(),
|
||||
passportSeries: seriesController.text.trim().isEmpty
|
||||
? null
|
||||
: seriesController.text.trim(),
|
||||
passportNumber: numberController.text.trim().isEmpty
|
||||
? null
|
||||
: numberController.text.trim(),
|
||||
passportIssueDate: _uiDateToApi(issueDateController.text),
|
||||
passportIssuer: issuerController.text.trim().isEmpty
|
||||
? null
|
||||
: issuerController.text.trim(),
|
||||
passportIssuerCode: issuerCodeController.text.trim().isEmpty
|
||||
? null
|
||||
: issuerCodeController.text.trim(),
|
||||
birthDate: _uiDateToApi(birthDateController.text),
|
||||
birthPlace: birthPlaceController.text.trim().isEmpty
|
||||
? null
|
||||
: birthPlaceController.text.trim(),
|
||||
citizenship: citizenshipController.text.trim().isEmpty
|
||||
? 'RU'
|
||||
: citizenshipController.text.trim().toUpperCase(),
|
||||
registrationAddress: registrationAddressController.text.trim().isEmpty
|
||||
? null
|
||||
: registrationAddressController.text.trim(),
|
||||
residenceAddress: residenceAddressController.text.trim().isEmpty
|
||||
? null
|
||||
: residenceAddressController.text.trim(),
|
||||
taxResidentUs: taxResidentUs,
|
||||
foreignTaxResident: foreignTaxResident,
|
||||
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
|
||||
sbpBankMemberId: selectedSbpBankMemberId,
|
||||
),
|
||||
cubit.state.acceptOffer,
|
||||
cubit.state.acceptFrdoAgreement,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectSbpBank(SbpBankModel bank) {
|
||||
setState(() {
|
||||
selectedSbpBankMemberId = bank.bankMemberId;
|
||||
selectedSbpBankName = bank.bankName;
|
||||
sbpBankController.text = bank.bankName;
|
||||
});
|
||||
_syncToCubit();
|
||||
}
|
||||
|
||||
void _clearSbpBankIfNeeded(String value) {
|
||||
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
|
||||
setState(() {
|
||||
selectedSbpBankMemberId = null;
|
||||
selectedSbpBankName = null;
|
||||
});
|
||||
_syncToCubit();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
|
||||
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
|
||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.fieldErrors != current.fieldErrors,
|
||||
builder: (context, state) {
|
||||
final errors = state.fieldErrors;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
left: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PassportScreenTitle(title: 'Паспортные данные'),
|
||||
const PassportSectionTitle(
|
||||
title: 'Персональные данные (как в паспорте)',
|
||||
),
|
||||
PassportFieldWithError(
|
||||
error: errors['last_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Фамилия',
|
||||
controller: lastNameController,
|
||||
hasError: errors.containsKey('last_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['first_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
hasError: errors.containsKey('first_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['middle_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
hasError: errors.containsKey('middle_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const PassportSectionTitle(title: 'Идентификация'),
|
||||
PassportFieldWithError(
|
||||
error: errors['inn'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'ИНН (12 цифр)',
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
hasError: errors.containsKey('inn'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['snils'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'СНИЛС',
|
||||
controller: snilsController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [snilsMask],
|
||||
hasError: errors.containsKey('snils'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['birth_date'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
|
||||
controller: birthDateController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [dateMaskBirth],
|
||||
hasError: errors.containsKey('birth_date'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['birth_place'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Место рождения',
|
||||
controller: birthPlaceController,
|
||||
hasError: errors.containsKey('birth_place'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['citizenship'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Гражданство (ISO, например RU)',
|
||||
controller: citizenshipController,
|
||||
maxLength: 2,
|
||||
hasError: errors.containsKey('citizenship'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['registration_address'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Адрес регистрации',
|
||||
controller: registrationAddressController,
|
||||
hasError: errors.containsKey('registration_address'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['residence_address'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Адрес проживания',
|
||||
controller: residenceAddressController,
|
||||
hasError: errors.containsKey('residence_address'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: taxResidentUs,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: appTheme.blueA400,
|
||||
title: Text(
|
||||
'Я являюсь налоговым резидентом США',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => taxResidentUs = value ?? false);
|
||||
_syncToCubit();
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: foreignTaxResident,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: appTheme.blueA400,
|
||||
title: Text(
|
||||
'Я являюсь иностранным налоговым резидентом',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => foreignTaxResident = value ?? false);
|
||||
_syncToCubit();
|
||||
},
|
||||
),
|
||||
const PassportSectionTitle(title: 'Паспорт'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PassportFieldWithError(
|
||||
error: errors['passport_series'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Серия',
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
hasError: errors.containsKey('passport_series'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: PassportFieldWithError(
|
||||
error: errors['passport_number'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Номер',
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
hasError: errors.containsKey('passport_number'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['passport_issue_date'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
|
||||
controller: issueDateController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [dateMaskIssue],
|
||||
hasError: errors.containsKey('passport_issue_date'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['passport_issuer_code'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Код подразделения',
|
||||
controller: issuerCodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [issuerCodeMask],
|
||||
hasError: errors.containsKey('passport_issuer_code'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['passport_issuer'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Кем выдан',
|
||||
controller: issuerController,
|
||||
hasError: errors.containsKey('passport_issuer'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const PassportSectionTitle(title: 'Реквизиты СБП'),
|
||||
PassportFieldWithError(
|
||||
error: errors['sbp_phone'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Телефон для выплат через СБП',
|
||||
controller: sbpPhoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [phoneMask],
|
||||
hasError: errors.containsKey('sbp_phone'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['sbp_bank_member_id'],
|
||||
child: TypeAheadField<SbpBankModel>(
|
||||
debounceDuration: const Duration(milliseconds: 250),
|
||||
controller: sbpBankController,
|
||||
suggestionsController: sbpBankSuggestionsController,
|
||||
builder: (context, controller, focusNode) =>
|
||||
CustomTextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
hintText: 'Банк получатель (поиск)',
|
||||
hasError: errors.containsKey('sbp_bank_member_id'),
|
||||
onChanged: _clearSbpBankIfNeeded,
|
||||
),
|
||||
suggestionsCallback: (query) async {
|
||||
return context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.searchSbpBanks(query);
|
||||
},
|
||||
itemBuilder: (context, bank) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
bank.bankName,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black900,
|
||||
),
|
||||
),
|
||||
subtitle:
|
||||
bank.shortName == null || bank.shortName!.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
bank.shortName!,
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.grey7B,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
decorationBuilder: (context, child) => Material(
|
||||
type: MaterialType.card,
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: appTheme.whiteA700,
|
||||
child: child,
|
||||
),
|
||||
emptyBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Ничего не найдено',
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.grey7B,
|
||||
),
|
||||
),
|
||||
),
|
||||
onSelected: _selectSbpBank,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
const Text(
|
||||
'Загрузите фото или сканы дипломов и сертификатов',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
builder: (context, state) {
|
||||
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
|
||||
final photos = state.certPhotos
|
||||
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
|
||||
.toList();
|
||||
final addedPhotos = state.newAddedPhotos;
|
||||
final totalPhotosCount = photos.length + addedPhotos.length;
|
||||
return Column(
|
||||
children: [
|
||||
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
|
||||
if (isUploading) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index == totalPhotosCount) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _pickAndUploadPhotos(),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(Icons.add, color: appTheme.blueA400),
|
||||
),
|
||||
);
|
||||
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
return _LocalCertPhotoCard(
|
||||
photo: localPhoto,
|
||||
allPhotos: addedPhotos,
|
||||
onRemove: () => _removePhoto(path: localPhoto.uri.path),
|
||||
onRemove: () =>
|
||||
_removePhoto(path: localPhoto.uri.path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
});
|
||||
try {
|
||||
// ignore: use_build_context_synchronously
|
||||
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
|
||||
await context.read<RegisterConsultantCubit>().addCertPhotos(
|
||||
newImages.map((file) => File(file.path)).toList(),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
final List<ConsultantCertPhotoModel> allPhotos;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
||||
const _CertPhotoCard({
|
||||
required this.photo,
|
||||
required this.allPhotos,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
|
||||
PhotoViewScreen(
|
||||
imageUrls: allPhotos.map((e) => e.url).toList(),
|
||||
isLocal: false,
|
||||
index: allPhotos.indexOf(photo),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.cover,
|
||||
image: NetworkImage(photo.url),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF1F2F6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
||||
final List<File> allPhotos;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
||||
const _LocalCertPhotoCard({
|
||||
required this.photo,
|
||||
required this.allPhotos,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF1F2F6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegistrationAsScreen extends StatefulWidget {
|
||||
const RegistrationAsScreen({super.key});
|
||||
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
|
||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||
|
||||
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
||||
static const RegistrationAsType registrationAsType =
|
||||
RegistrationAsType.physical;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final cubit = context.read<RegisterConsultantCubit>();
|
||||
cubit.updateConsultantProfileModel(
|
||||
cubit.state.consultant.copyWith(
|
||||
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||
),
|
||||
cubit.state.acceptOffer,
|
||||
cubit.state.acceptFrdoAgreement,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
child: Text(
|
||||
'Регистрация в качестве',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Column(
|
||||
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: (_) {},
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Физического лица',
|
||||
style: TextStyle(fontSize: 18, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Самозанятого',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Индивидуального предпринимателя',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Общества с ограниченной ответственностью',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: _divider(context),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
|
||||
child: Text(
|
||||
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
return Container(
|
||||
height: 0.4,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
|
||||
var maskFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
var maskTgFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://tg.me/########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
var maskVkFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://vk.com/########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
|
||||
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
|
||||
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
|
||||
vkController.text =
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks!
|
||||
.vkLink ??
|
||||
'';
|
||||
telegramController.text =
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks!
|
||||
.tgLink ??
|
||||
'';
|
||||
webSiteController.text =
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks!
|
||||
.personalSiteLink ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/vk.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/vk.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/telegram.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/telegram.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/web.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/web.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
String? personalSiteLink,
|
||||
}) {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
|
||||
tgLink: tgLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
|
||||
: tgLink.isEmpty
|
||||
? null
|
||||
: tgLink,
|
||||
vkLink: vkLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink
|
||||
: vkLink.isEmpty
|
||||
? null
|
||||
: vkLink,
|
||||
personalSiteLink: personalSiteLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink
|
||||
: personalSiteLink.isEmpty
|
||||
? null
|
||||
: personalSiteLink,
|
||||
),
|
||||
),
|
||||
);
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
socialLinks: context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.copyWith(
|
||||
tgLink: tgLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.tgLink
|
||||
: tgLink.isEmpty
|
||||
? null
|
||||
: tgLink,
|
||||
vkLink: vkLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.vkLink
|
||||
: vkLink.isEmpty
|
||||
? null
|
||||
: vkLink,
|
||||
personalSiteLink: personalSiteLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.personalSiteLink
|
||||
: personalSiteLink.isEmpty
|
||||
? null
|
||||
: personalSiteLink,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||
@@ -21,39 +23,145 @@ import 'package:lottie/lottie.dart';
|
||||
class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||
final String? slug;
|
||||
final bool? isCanceled;
|
||||
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
|
||||
final bool isNewRegistration;
|
||||
const StepperBecomeConsultantScreen({
|
||||
super.key,
|
||||
this.slug,
|
||||
this.isCanceled,
|
||||
this.isNewRegistration = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
|
||||
State<StepperBecomeConsultantScreen> createState() =>
|
||||
_StepperBecomeConsultantScreenState();
|
||||
}
|
||||
|
||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 1 / 6;
|
||||
class _StepperBecomeConsultantScreenState
|
||||
extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 0;
|
||||
int step = 1;
|
||||
final pageController = PageController();
|
||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||
bool isLoading = false;
|
||||
bool _successSheetShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.slug == null) {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
|
||||
if (widget.isNewRegistration || widget.slug == null) {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||
..disableLoading(slug: widget.slug);
|
||||
} else {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||
..getMyRequestStatus(widget.slug!);
|
||||
}
|
||||
}
|
||||
|
||||
static const int totalSteps = 7;
|
||||
|
||||
List<Widget> _pages() {
|
||||
return const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
PassportDataScreen(),
|
||||
LastStepScreen(),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showSuccessSheet(BuildContext context) async {
|
||||
if (_successSheetShown) {
|
||||
return;
|
||||
}
|
||||
_successSheetShown = true;
|
||||
await showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Lottie.asset(
|
||||
'assets/lottie_animations/success.json',
|
||||
width: 160,
|
||||
height: 160,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null) {
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
@@ -72,248 +180,320 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
},
|
||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(state.requestModel!.canceledModeration!.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: isLoading
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
if (step == 6) {
|
||||
if (widget.isCanceled == true) {
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit.state.acceptOffer) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error != null) {
|
||||
return;
|
||||
}
|
||||
await showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: const Text(
|
||||
'Заполните паспортные данные',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: Text(e.toString()),
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: const Text('Примите договор публичной офёрты'),
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
label: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true
|
||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
||||
),
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / 6;
|
||||
});
|
||||
},
|
||||
children: const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
LastStepScreen(),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ScoringStatusWidget extends StatelessWidget {
|
||||
const ScoringStatusWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.alfaScoringStatus != current.alfaScoringStatus ||
|
||||
previous.isScoringPolling != current.isScoringPolling ||
|
||||
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
|
||||
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
|
||||
builder: (context, state) {
|
||||
final isWaiting =
|
||||
state.awaitingScoringOutcome || state.isScoringPolling;
|
||||
final status = state.alfaScoringStatus;
|
||||
final showProcessing = status.isProcessing ||
|
||||
(isWaiting && !status.isTerminal);
|
||||
|
||||
if (status == AlfaScoringStatus.none && !isWaiting) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final displayStatus = showProcessing && !status.isProcessing
|
||||
? AlfaScoringStatus.beneficiaryCreating
|
||||
: status;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: appTheme.greyD9),
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ScoringStatusHeader(status: displayStatus),
|
||||
const SizedBox(height: 12),
|
||||
if (showProcessing) const ScoringProcessingBody(),
|
||||
if (status.isComplete) const ScoringCompleteBody(),
|
||||
if (status.isFailed)
|
||||
ScoringFailedBody(
|
||||
retryInProgress: state.scoringRetryInProgress,
|
||||
onRetry: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.retryScoring(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringStatusHeader extends StatelessWidget {
|
||||
const ScoringStatusHeader({required this.status, super.key});
|
||||
|
||||
final AlfaScoringStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Проверка данных',
|
||||
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
ScoringStatusLabels.hintFor(status),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: ScoringStatusLabels.colorFor(status),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringProcessingBody extends StatelessWidget {
|
||||
const ScoringProcessingBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Идёт проверка…',
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringCompleteBody extends StatelessWidget {
|
||||
const ScoringCompleteBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: appTheme.green, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Проверка успешно завершена',
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringFailedBody extends StatelessWidget {
|
||||
const ScoringFailedBody({
|
||||
required this.retryInProgress,
|
||||
required this.onRetry,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool retryInProgress;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: retryInProgress ? null : onRetry,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appTheme.blueA400,
|
||||
disabledBackgroundColor: appTheme.greyD9,
|
||||
foregroundColor: appTheme.whiteA700,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: retryInProgress
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Повторить',
|
||||
style: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class ScoringStatusLabels {
|
||||
static String hintFor(AlfaScoringStatus status) {
|
||||
return switch (status) {
|
||||
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
|
||||
AlfaScoringStatus.scoringPending => 'Ожидание результата',
|
||||
AlfaScoringStatus.scoringComplete => 'Успешно',
|
||||
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
|
||||
AlfaScoringStatus.scoringFailed => 'Ошибка',
|
||||
AlfaScoringStatus.none => '',
|
||||
};
|
||||
}
|
||||
|
||||
static Color colorFor(AlfaScoringStatus status) {
|
||||
return switch (status) {
|
||||
AlfaScoringStatus.scoringComplete => appTheme.green,
|
||||
AlfaScoringStatus.beneficiaryFailed ||
|
||||
AlfaScoringStatus.scoringFailed => appTheme.red,
|
||||
AlfaScoringStatus.beneficiaryCreating ||
|
||||
AlfaScoringStatus.scoringPending => appTheme.blueA400,
|
||||
AlfaScoringStatus.none => appTheme.grey7B,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
@@ -11,36 +12,53 @@ class ConsultantRepository {
|
||||
|
||||
const ConsultantRepository({required this.consultantService});
|
||||
|
||||
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
|
||||
Future<void> registerConsultant({
|
||||
required ConsultantProfileModel consultant,
|
||||
bool update = false,
|
||||
}) async {
|
||||
if (update) {
|
||||
return await consultantService.consultantUpdate(consultantModel: consultant);
|
||||
return await consultantService.consultantUpdate(
|
||||
consultantModel: consultant,
|
||||
);
|
||||
} else {
|
||||
return await consultantService.consultantRegistration(consultantModel: consultant);
|
||||
return await consultantService.consultantRegistration(
|
||||
consultantModel: consultant,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
|
||||
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
|
||||
Future<ConsultationRequestModel> getConsultantRequestStatus(
|
||||
String slug,
|
||||
) async {
|
||||
return await consultantService.getConsultantRequestStatus(slug: slug);
|
||||
|
||||
}
|
||||
|
||||
/// Своя заявка — education + legal/passport для регистрации и update.
|
||||
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
||||
return await consultantService.getMyRequestStatus(slug: slug);
|
||||
}
|
||||
|
||||
Future<List<SbpBankModel>> getSbpBanks() async {
|
||||
return await consultantService.getSbpBanks();
|
||||
}
|
||||
|
||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
||||
return await consultantService.getConsultantCertPhotos();
|
||||
|
||||
}
|
||||
|
||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
|
||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||
required List<File> files,
|
||||
}) async {
|
||||
final multipartFiles = <MultipartFile>[];
|
||||
|
||||
for (final file in files) {
|
||||
final fileBytes = await file.readAsBytes();
|
||||
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
|
||||
final multipartFile = MultipartFile.fromBytes(
|
||||
fileBytes,
|
||||
filename: file.path.split('/').last,
|
||||
contentType: MediaType('application', 'octet-stream'),
|
||||
);
|
||||
multipartFiles.add(multipartFile);
|
||||
}
|
||||
|
||||
@@ -48,22 +66,24 @@ class ConsultantRepository {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
|
||||
|
||||
return await consultantService.storeConsultantCertPhotos(
|
||||
photos: multipartFiles,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> hideConsultantCertPhoto(int id) async {
|
||||
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
||||
|
||||
await consultantService.hideConsultantCertPhoto(
|
||||
payload: {'cert_photo_id': id},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> unHideConsultantCertPhoto(int id) async {
|
||||
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
||||
|
||||
await consultantService.unHideConsultantCertPhoto(
|
||||
payload: {'cert_photo_id': id},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePhoto(int id) async {
|
||||
await consultantService.deletePhoto(id: id);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
part 'consultant_service.g.dart';
|
||||
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
|
||||
abstract class ConsultantService {
|
||||
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
||||
|
||||
@POST('/api/consultant_request/registration')
|
||||
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
|
||||
@POST('/api/v2/consultant_request/registration')
|
||||
Future<void> consultantRegistration({
|
||||
@Body() required ConsultantProfileModel consultantModel,
|
||||
});
|
||||
|
||||
@POST('/api/consultant_request/photos/upload')
|
||||
@MultiPart()
|
||||
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
|
||||
Future<List<String>> uploadPhotos({
|
||||
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||
List<MultipartFile>? photos,
|
||||
});
|
||||
|
||||
/// Публичный статус консультанта для просмотра профиля.
|
||||
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
|
||||
@GET('/api/profile/consultant/{slug}/status')
|
||||
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
|
||||
Future<ConsultationRequestModel> getConsultantRequestStatus({
|
||||
@Path() required String slug,
|
||||
});
|
||||
|
||||
@GET('/api/consultant_request/{slug}/status')
|
||||
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
|
||||
/// Свой статус заявки для редактирования/модерации.
|
||||
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
|
||||
@GET('/api/v2/consultant_request/{slug}/status')
|
||||
Future<ConsultationRequestModel> getMyRequestStatus({
|
||||
@Path() required String slug,
|
||||
});
|
||||
|
||||
@PUT('/api/consultant_request/update')
|
||||
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
|
||||
@PUT('/api/v2/consultant_request/update')
|
||||
Future<void> consultantUpdate({
|
||||
@Body() required ConsultantProfileModel consultantModel,
|
||||
});
|
||||
|
||||
@GET('/api/finances/sbp_banks')
|
||||
Future<List<SbpBankModel>> getSbpBanks();
|
||||
|
||||
@GET('/api/v2/consultant_request/cert_photos')
|
||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
||||
@@ -32,14 +51,19 @@ abstract class ConsultantService {
|
||||
@POST('/api/v2/consultant_request/cert_photos/store')
|
||||
@MultiPart()
|
||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
|
||||
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||
required List<MultipartFile> photos,
|
||||
});
|
||||
|
||||
@POST('/api/v2/consultant_request/cert_photos/hide')
|
||||
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
||||
Future<void> hideConsultantCertPhoto({
|
||||
@Body() required Map<String, dynamic> payload,
|
||||
});
|
||||
|
||||
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
||||
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
||||
Future<void> unHideConsultantCertPhoto({
|
||||
@Body() required Map<String, dynamic> payload,
|
||||
});
|
||||
|
||||
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
||||
Future<void> deletePhoto({@Path() required int id});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Parsed Laravel 422 validation payload for consultant registration UI.
|
||||
class LaravelValidationErrors {
|
||||
const LaravelValidationErrors({
|
||||
required this.summary,
|
||||
this.fieldErrors = const <String, String>{},
|
||||
});
|
||||
|
||||
final String summary;
|
||||
final Map<String, String> fieldErrors;
|
||||
|
||||
static const LaravelValidationErrors empty = LaravelValidationErrors(
|
||||
summary: '',
|
||||
);
|
||||
|
||||
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||
}
|
||||
|
||||
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
|
||||
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||
/// Raw backend messages are never shown in the UI.
|
||||
LaravelValidationErrors parseLaravelValidationErrors(Object error) {
|
||||
if (error is! DioException) {
|
||||
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||
}
|
||||
|
||||
final data = error.response?.data;
|
||||
if (error.response?.statusCode == 422 && data is Map) {
|
||||
final fieldErrors = <String, String>{};
|
||||
final errors = data['errors'];
|
||||
|
||||
if (errors is Map) {
|
||||
errors.forEach((key, value) {
|
||||
final fieldKey = key.toString();
|
||||
final raw = _rawErrorText(value);
|
||||
fieldErrors[fieldKey] = mapConsultantValidationMessage(
|
||||
fieldKey: fieldKey,
|
||||
rawMessage: raw,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (fieldErrors.isNotEmpty) {
|
||||
final uniqueMessages = fieldErrors.values.toSet().toList();
|
||||
return LaravelValidationErrors(
|
||||
summary: uniqueMessages.join('\n'),
|
||||
fieldErrors: fieldErrors,
|
||||
);
|
||||
}
|
||||
|
||||
return const LaravelValidationErrors(summary: _genericValidationSummary);
|
||||
}
|
||||
|
||||
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||
}
|
||||
|
||||
String _rawErrorText(Object? value) {
|
||||
if (value is List && value.isNotEmpty) {
|
||||
return value.map((e) => e.toString()).join(' ');
|
||||
}
|
||||
if (value is String) {
|
||||
return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Maps a backend validation message for [fieldKey] to a short Russian UI string.
|
||||
String mapConsultantValidationMessage({
|
||||
required String fieldKey,
|
||||
required String rawMessage,
|
||||
}) {
|
||||
final field = fieldKey.split('.').last.toLowerCase();
|
||||
final raw = rawMessage.toLowerCase();
|
||||
|
||||
final isUnique =
|
||||
raw.contains('taken') ||
|
||||
raw.contains('unique') ||
|
||||
raw.contains('already') ||
|
||||
raw.contains('уже существ') ||
|
||||
raw.contains('уже занят');
|
||||
final isRequired =
|
||||
raw.contains('required') ||
|
||||
raw.contains('обязательн') ||
|
||||
raw.contains('required_if');
|
||||
final isAccepted =
|
||||
raw.contains('accepted') || field.contains('agreement');
|
||||
final isUrl = raw.contains('url') || field.contains('link');
|
||||
final isFormat =
|
||||
raw.contains('format') ||
|
||||
raw.contains('invalid') ||
|
||||
raw.contains('regex') ||
|
||||
raw.contains('digits') ||
|
||||
raw.contains('size') ||
|
||||
raw.contains('date') ||
|
||||
raw.contains('before');
|
||||
|
||||
if (isAccepted) {
|
||||
if (field.contains('frdo')) {
|
||||
return 'Подтвердите согласие ФРДО';
|
||||
}
|
||||
return 'Примите условия оферты';
|
||||
}
|
||||
|
||||
if (isUnique) {
|
||||
return switch (field) {
|
||||
'inn' => 'Такой ИНН существует',
|
||||
'snils' => 'Такой СНИЛС существует',
|
||||
'passport_number' || 'passport_series' => 'Такой паспорт существует',
|
||||
'sbp_phone' => 'Такой телефон СБП существует',
|
||||
_ => 'Такие данные уже существуют',
|
||||
};
|
||||
}
|
||||
|
||||
if (isRequired) {
|
||||
return switch (field) {
|
||||
'inn' => 'Укажите ИНН',
|
||||
'snils' => 'Укажите СНИЛС',
|
||||
'last_name' => 'Укажите фамилию',
|
||||
'first_name' => 'Укажите имя',
|
||||
'middle_name' => 'Укажите отчество',
|
||||
'passport_series' => 'Укажите серию паспорта',
|
||||
'passport_number' => 'Укажите номер паспорта',
|
||||
'passport_issue_date' => 'Укажите дату выдачи паспорта',
|
||||
'passport_issuer' => 'Укажите, кем выдан паспорт',
|
||||
'passport_issuer_code' => 'Укажите код подразделения',
|
||||
'birth_date' => 'Укажите дату рождения',
|
||||
'birth_place' => 'Укажите место рождения',
|
||||
'registration_address' => 'Укажите адрес регистрации',
|
||||
'residence_address' => 'Укажите адрес проживания',
|
||||
'citizenship' => 'Укажите гражданство',
|
||||
'sbp_phone' => 'Укажите телефон СБП',
|
||||
'sbp_bank_member_id' => 'Выберите банк СБП',
|
||||
'completed_courses' => 'Добавьте хотя бы один курс',
|
||||
'title' => 'Укажите название курса',
|
||||
'date' => 'Укажите дату курса',
|
||||
'agreement_accepted' => 'Примите условия оферты',
|
||||
'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО',
|
||||
_ => 'Заполните поле',
|
||||
};
|
||||
}
|
||||
|
||||
if (isUrl) {
|
||||
return 'Некорректная ссылка';
|
||||
}
|
||||
|
||||
if (isFormat || raw.isNotEmpty) {
|
||||
return switch (field) {
|
||||
'inn' => 'Некорректный ИНН',
|
||||
'snils' => 'Некорректный СНИЛС',
|
||||
'passport_series' => 'Некорректная серия паспорта',
|
||||
'passport_number' => 'Некорректный номер паспорта',
|
||||
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||
'birth_date' => 'Некорректная дата рождения',
|
||||
'citizenship' => 'Некорректный код гражданства',
|
||||
'sbp_phone' => 'Некорректный телефон СБП',
|
||||
'title' => 'Слишком короткое название курса',
|
||||
'date' => 'Некорректная дата курса',
|
||||
_ => 'Некорректное значение',
|
||||
};
|
||||
}
|
||||
|
||||
return 'Проверьте поле';
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -21,9 +20,6 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
||||
@JsonKey(name: "total_views") required int totalViews,
|
||||
@JsonKey(name: "total_comments") required int totalComments,
|
||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
}) = _FeedBlogPostModel;
|
||||
|
||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
|
||||
class PostFeedMapper {
|
||||
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
|
||||
return FeedBlogPostModel(
|
||||
id: item.postId,
|
||||
state: item.postState,
|
||||
image: resolveMediaUrl(item.postImageUrl),
|
||||
description: item.postTextMd ?? item.postShortDescription ?? '',
|
||||
author: Author(
|
||||
authorSlug: item.postAuthorDetails?.slug ?? '',
|
||||
authorName: item.postAuthorName,
|
||||
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
|
||||
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
|
||||
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: item.postCreatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
title: item.postTitle,
|
||||
totalLikes: item.postLikesCount,
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'post_feed_pagination_model.freezed.dart';
|
||||
part 'post_feed_pagination_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
|
||||
const factory PostFeedPaginationModel({
|
||||
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
|
||||
required CursorLinks links,
|
||||
required CursorMeta meta,
|
||||
}) = _PostFeedPaginationModel;
|
||||
|
||||
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedItemModel with _$PostFeedItemModel {
|
||||
const factory PostFeedItemModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'post_id') required int postId,
|
||||
@JsonKey(name: 'post_author_id') required int postAuthorId,
|
||||
@JsonKey(name: 'post_author_name') required String postAuthorName,
|
||||
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
|
||||
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
|
||||
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
|
||||
@JsonKey(name: 'post_title') required String postTitle,
|
||||
@JsonKey(name: 'post_short_description') String? postShortDescription,
|
||||
@JsonKey(name: 'post_text_md') String? postTextMd,
|
||||
@JsonKey(name: 'post_image_url') String? postImageUrl,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
|
||||
@JsonKey(name: 'post_views_count') required int postViewsCount,
|
||||
@JsonKey(name: 'post_likes_count') required int postLikesCount,
|
||||
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
|
||||
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
|
||||
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: 'post_url') String? postUrl,
|
||||
@JsonKey(name: 'post_state') required String postState,
|
||||
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
|
||||
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
|
||||
}) = _PostFeedItemModel;
|
||||
|
||||
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||
const factory FeedGalleryImage({
|
||||
required String url,
|
||||
@Default('horizontal') String orientation,
|
||||
int? id,
|
||||
int? position,
|
||||
}) = _FeedGalleryImage;
|
||||
|
||||
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostAuthorDetails with _$PostAuthorDetails {
|
||||
const factory PostAuthorDetails({
|
||||
required String slug,
|
||||
@JsonKey(name: 'is_consultant') required bool isConsultant,
|
||||
}) = _PostAuthorDetails;
|
||||
|
||||
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorLinks with _$CursorLinks {
|
||||
const factory CursorLinks({
|
||||
required String? first,
|
||||
required String? last,
|
||||
required String? prev,
|
||||
required String? next,
|
||||
}) = _CursorLinks;
|
||||
|
||||
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorMeta with _$CursorMeta {
|
||||
const factory CursorMeta({
|
||||
required String path,
|
||||
@JsonKey(name: 'per_page') required int perPage,
|
||||
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||
}) = _CursorMeta;
|
||||
|
||||
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
|
||||
}
|
||||
|
||||
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();
|
||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class FeedCubit extends Cubit<FeedState> {
|
||||
final FeedRepository feedRepository;
|
||||
String? _nextCursor;
|
||||
bool _hasMore = true;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
String? _query;
|
||||
|
||||
@@ -38,16 +38,13 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||
Future<void> loadFeedBlogPostList({String? query}) async {
|
||||
try {
|
||||
_query = query;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
||||
lastPage = feedBlogPosts.meta.lastPage;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -57,35 +54,29 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNextPage({int? currentUserId}) async {
|
||||
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||
return;
|
||||
}
|
||||
Future<void> loadNextPage() async {
|
||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
||||
isLoading = true;
|
||||
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
if (!isClosed) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
final state = feedCubit.state;
|
||||
// Загружаем данные, если они еще не загружены
|
||||
if (state is! FeedStateLoaded) {
|
||||
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||
feedCubit.loadFeedBlogPostList();
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
@@ -201,10 +201,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
query: query,
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans(query: query);
|
||||
@@ -227,9 +224,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Gallery + optional embed video for feed card and blog post detail.
|
||||
class BlogPostMediaContent extends StatefulWidget {
|
||||
const BlogPostMediaContent({
|
||||
super.key,
|
||||
required this.galleryImages,
|
||||
required this.imageUrl,
|
||||
required this.videoEmbedUrl,
|
||||
required this.currentGalleryIndex,
|
||||
required this.onOpenGallery,
|
||||
required this.onGalleryChanged,
|
||||
required this.videoUrlThumb,
|
||||
});
|
||||
|
||||
final List<FeedGalleryImage> galleryImages;
|
||||
final String imageUrl;
|
||||
final String? videoEmbedUrl;
|
||||
final String? videoUrlThumb;
|
||||
final int currentGalleryIndex;
|
||||
final void Function(List<String> urls, int index) onOpenGallery;
|
||||
final ValueChanged<int> onGalleryChanged;
|
||||
|
||||
@override
|
||||
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||
}
|
||||
|
||||
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||
|
||||
/// Landscape / video slide aspect (width / height).
|
||||
static const _horizontalAspectRatio = 16 / 9;
|
||||
|
||||
/// Portrait slide aspect (width / height).
|
||||
static const _verticalAspectRatio = 3 / 4;
|
||||
|
||||
bool _isCounterVisible = false;
|
||||
Timer? _counterHideTimer;
|
||||
|
||||
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||
|
||||
List<FeedGalleryImage> get _gallery {
|
||||
return [
|
||||
for (final image in widget.galleryImages)
|
||||
if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty)
|
||||
FeedGalleryImage(
|
||||
url: url,
|
||||
orientation: image.orientation,
|
||||
id: image.id,
|
||||
position: image.position,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
|
||||
|
||||
int get _slideCount {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.length + (_hasVideo ? 1 : 0);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length;
|
||||
|
||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||
|
||||
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||
String get _postOrientation {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.first.orientation;
|
||||
}
|
||||
return 'horizontal';
|
||||
}
|
||||
|
||||
double _heightForOrientation(String orientation, double width) {
|
||||
final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio;
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_counterHideTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleCounterHideOnVideo() {
|
||||
_counterHideTimer?.cancel();
|
||||
if (!_isCurrentSlideVideo) return;
|
||||
|
||||
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
||||
if (mounted && _isCurrentSlideVideo) {
|
||||
setState(() => _isCounterVisible = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
|
||||
_counterHideTimer?.cancel();
|
||||
if (!_isCounterVisible) {
|
||||
setState(() => _isCounterVisible = true);
|
||||
}
|
||||
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
height: height,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoSlide() {
|
||||
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
||||
}
|
||||
|
||||
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) {
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleCarouselScroll,
|
||||
child: CarouselSlider.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||
options: CarouselOptions(
|
||||
viewportFraction: 1,
|
||||
height: height,
|
||||
enableInfiniteScroll: false,
|
||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (itemCount > 1)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _shouldShowCounter ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gallery = _gallery;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||
|
||||
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||
final itemCount = _slideCount;
|
||||
return _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (gallery.length == 1) {
|
||||
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||
}
|
||||
|
||||
if (_hasVideo) {
|
||||
return SizedBox(
|
||||
height: mediaHeight,
|
||||
width: width,
|
||||
child: _videoSlide(),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.imageUrl.isNotEmpty) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
child: Hero(
|
||||
tag: widget.imageUrl,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: width,
|
||||
imageUrl: widget.imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryPhotoCounter extends StatelessWidget {
|
||||
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||
|
||||
final int currentIndex;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${currentIndex + 1} / $total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +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';
|
||||
@@ -8,10 +9,8 @@ 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/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.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';
|
||||
@@ -20,9 +19,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class BlogPostWidget extends StatefulWidget {
|
||||
final FeedBlogPostModel post;
|
||||
final int index;
|
||||
|
||||
const BlogPostWidget({super.key, required this.post});
|
||||
const BlogPostWidget({super.key, required this.index});
|
||||
|
||||
@override
|
||||
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
||||
@@ -31,267 +30,243 @@ class BlogPostWidget extends StatefulWidget {
|
||||
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
late bool isLiked;
|
||||
late int totalLikes;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
FeedBlogPostModel get post => widget.post;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.post.id != post.id) {
|
||||
currentGalleryIndex = 0;
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
return;
|
||||
}
|
||||
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) {
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
}
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||
}
|
||||
|
||||
Future<void> _toggleLike() async {
|
||||
final nextLiked = !isLiked;
|
||||
setState(() {
|
||||
isLiked = nextLiked;
|
||||
nextLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
|
||||
if (nextLiked) {
|
||||
await context.read<FeedCubit>().likePost(post.id);
|
||||
} else {
|
||||
await context.read<FeedCubit>().unlikedPost(post.id);
|
||||
}
|
||||
}
|
||||
bool isMoreOptionsVisible = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
return BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
|
||||
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
|
||||
? state.feedBlogPostList[widget.index].author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Имя и дата внутри Expanded
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.feedBlogPostList[widget.index].author.authorName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Кнопка подписки (если нужно)
|
||||
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
|
||||
!state.feedBlogPostList[widget.index].author.authorIsMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
|
||||
),
|
||||
// Кнопка меню
|
||||
CustomPopup(
|
||||
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
|
||||
id: state.feedBlogPostList[widget.index].id,
|
||||
feedBlogPostModel: state.feedBlogPostList[widget.index],
|
||||
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
state.feedBlogPostList[widget.index].title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.feedBlogPostList[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
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 MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
// Update feed list immediately by ID (no reload)
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
|
||||
// Fire network call without introducing UI delay
|
||||
if (isLiked) {
|
||||
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
|
||||
} else {
|
||||
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
text: state.feedBlogPostList[widget.index].totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 15,
|
||||
width: 12,
|
||||
'assets/icons/repost.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
state.feedBlogPostList[widget.index].totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostHeader extends StatelessWidget {
|
||||
const _BlogPostHeader({required this.post});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null,
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||
),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
feedBlogPostModel: post,
|
||||
userSlug: post.author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (post.title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
post.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
|
||||
if (post.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
|
||||
),
|
||||
if (post.description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostActionsRow extends StatelessWidget {
|
||||
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool isLiked;
|
||||
final int totalLikes;
|
||||
final Future<void> Function() onToggleLike;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: onToggleLike,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
||||
text: post.totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
post.totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _truncateDescription(String description) {
|
||||
final isLong = description.length > 200;
|
||||
if (!isLong) return description;
|
||||
|
||||
var cutPoint = 200;
|
||||
final preview = description.substring(0, cutPoint);
|
||||
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
final hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
if (hasUnclosedBold) {
|
||||
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
final boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
return description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -12,18 +11,18 @@ import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class BlogPosts extends StatelessWidget {
|
||||
const BlogPosts({super.key, required this.handleVisibility});
|
||||
final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
|
||||
|
||||
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
|
||||
child: BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
|
||||
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
|
||||
return false;
|
||||
},
|
||||
child: ListView.separated(
|
||||
@@ -33,13 +32,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) {
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
|
||||
final id = state.feedBlogPostList[index].id;
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
|
||||
},
|
||||
child: BlogPostWidget(post: post),
|
||||
key: ValueKey(index),
|
||||
child: BlogPostWidget(index: index),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
@@ -47,9 +46,7 @@ class BlogPosts extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else if (state is FeedStateError) {
|
||||
return DataErrorWidget(
|
||||
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
);
|
||||
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList());
|
||||
} else {
|
||||
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||
|
||||
enum FeedVideoEmbedProvider { vk, other }
|
||||
|
||||
/// Сборка URL и HTML для embed-плеера ленты.
|
||||
abstract final class FeedVideoEmbed {
|
||||
static FeedVideoEmbedProvider detectProvider(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
final isVk = host == 'vk.com' ||
|
||||
host.endsWith('.vk.com') ||
|
||||
host == 'vk.ru' ||
|
||||
host.endsWith('.vk.ru') ||
|
||||
host == 'vkvideo.ru' ||
|
||||
host.endsWith('.vkvideo.ru');
|
||||
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
|
||||
}
|
||||
|
||||
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
|
||||
static Uri uriForPlayback(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
final params = Map<String, String>.from(uri.queryParameters);
|
||||
params['autoplay'] = '1';
|
||||
params['mute'] = '1';
|
||||
params.putIfAbsent('playsinline', () => '1');
|
||||
|
||||
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
|
||||
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
|
||||
params['js_api'] = '1';
|
||||
}
|
||||
|
||||
return uri.replace(queryParameters: params);
|
||||
}
|
||||
|
||||
static String buildHtml(String embedUrl) {
|
||||
final uri = Uri.parse(embedUrl);
|
||||
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
|
||||
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
|
||||
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
|
||||
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
|
||||
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
$headScripts
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
id="player"
|
||||
src="$src"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
></iframe>
|
||||
<script>
|
||||
$bootstrap
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/// JS-фрагменты для embed-плеера ленты.
|
||||
///
|
||||
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
|
||||
abstract final class FeedVideoEmbedScripts {
|
||||
static const readyChannel = 'EmbedReady';
|
||||
|
||||
/// Уведомляет Flutter, что embed готов к показу.
|
||||
static String get notifyReady =>
|
||||
'''
|
||||
function notifyReady() {
|
||||
try {
|
||||
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
|
||||
} catch (e) {}
|
||||
}
|
||||
''';
|
||||
|
||||
/// Уведомляет Flutter об ошибке воспроизведения.
|
||||
static String get notifyError =>
|
||||
'''
|
||||
function notifyError() {
|
||||
try {
|
||||
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
|
||||
} catch (e) {}
|
||||
}
|
||||
''';
|
||||
|
||||
/// Rutube: готовность через postMessage `player:canPlay`.
|
||||
static String get rutubeCanPlayListener => '''
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
var type = JSON.parse(event.data).type;
|
||||
if (type === 'player:canPlay') {
|
||||
notifyReady();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
''';
|
||||
|
||||
/// VK Video JS API: воспроизведение началось на событии `started`.
|
||||
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
|
||||
static String get vkPlayerReadyHandler => '''
|
||||
try {
|
||||
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
|
||||
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
|
||||
vkPlayer.on('started', function () {
|
||||
notifyReady();
|
||||
});
|
||||
vkPlayer.on('error', function () {
|
||||
notifyError();
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
''';
|
||||
|
||||
/// Внешний скрипт VK VideoPlayer API.
|
||||
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
|
||||
|
||||
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
|
||||
static String bootstrap({required bool includeVk}) {
|
||||
final vkPart = includeVk ? vkPlayerReadyHandler : '';
|
||||
return '''
|
||||
(function () {
|
||||
$notifyReady
|
||||
$notifyError
|
||||
$rutubeCanPlayListener
|
||||
$vkPart
|
||||
})();
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
/// Embed-видео в ленте.
|
||||
///
|
||||
/// 1) Пока пользователь не нажал play — только превью (без WebView).
|
||||
/// 2) После play — превью + лоадер, WebView грузится скрыто.
|
||||
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
|
||||
///
|
||||
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
|
||||
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/VK/YouTube):
|
||||
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
|
||||
class FeedVideoPlayer extends StatefulWidget {
|
||||
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
|
||||
|
||||
final String embedUrl;
|
||||
|
||||
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
|
||||
final String? videoUrlThumb;
|
||||
|
||||
@override
|
||||
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
|
||||
}
|
||||
|
||||
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
static const Duration _readyFallbackDelay = Duration(seconds: 4);
|
||||
|
||||
/// Пока null — WebView не рендерится. Заполняется по жесту play.
|
||||
InAppWebViewInitialData? _initialData;
|
||||
bool _isLoading = false;
|
||||
bool _isReady = false;
|
||||
bool _hasError = false;
|
||||
|
||||
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
|
||||
Timer? _readyFallback;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
|
||||
_reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_readyFallback?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
_initialData = null;
|
||||
_isLoading = false;
|
||||
_isReady = false;
|
||||
_hasError = false;
|
||||
}
|
||||
|
||||
void _markReady() {
|
||||
if (!mounted || _isReady || _hasError) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_isReady = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _markError() {
|
||||
if (!mounted || _isReady) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
_initialData = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPlayPressed() async {
|
||||
if (_isLoading || _isReady || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
});
|
||||
|
||||
try {
|
||||
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
|
||||
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_initialData = InAppWebViewInitialData(
|
||||
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
|
||||
mimeType: 'text/html',
|
||||
encoding: 'utf8',
|
||||
baseUrl: WebUri(baseUrl),
|
||||
);
|
||||
});
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('FeedVideoPlayer load failed: $e\n$st');
|
||||
}
|
||||
_markError();
|
||||
}
|
||||
}
|
||||
|
||||
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
|
||||
iframeAllowFullscreen: true,
|
||||
transparentBackground: false,
|
||||
);
|
||||
|
||||
void _onWebViewCreated(InAppWebViewController controller) {
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: FeedVideoEmbedScripts.readyChannel,
|
||||
callback: (args) {
|
||||
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
|
||||
if (status == 'error') {
|
||||
_markError();
|
||||
} else {
|
||||
_markReady();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onLoadStop(InAppWebViewController _, WebUri? _) {
|
||||
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
|
||||
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
|
||||
if (_isReady || _hasError || !mounted) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = Timer(_readyFallbackDelay, () {
|
||||
if (mounted && !_isReady && !_hasError) _markReady();
|
||||
});
|
||||
}
|
||||
|
||||
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
|
||||
if (!mounted) return;
|
||||
if (request.isForMainFrame != true) return;
|
||||
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
|
||||
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||
if (_isReady) return;
|
||||
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
|
||||
_markError();
|
||||
}
|
||||
|
||||
void _onEnterFullscreen(InAppWebViewController _) {
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||
}
|
||||
|
||||
void _onExitFullscreen(InAppWebViewController _) {
|
||||
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// WebView создаётся только после play и до ready остаётся скрытым под превью.
|
||||
if (_initialData != null)
|
||||
Opacity(
|
||||
opacity: _isReady ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isReady,
|
||||
child: InAppWebView(
|
||||
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
|
||||
initialData: _initialData,
|
||||
initialSettings: _settings,
|
||||
onWebViewCreated: _onWebViewCreated,
|
||||
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
|
||||
onLoadStop: _onLoadStop,
|
||||
onReceivedError: _onReceivedError,
|
||||
onEnterFullscreen: _onEnterFullscreen,
|
||||
onExitFullscreen: _onExitFullscreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isReady)
|
||||
FeedVideoPreviewPlaceholder(
|
||||
previewUrl: widget.videoUrlThumb,
|
||||
isLoading: _isLoading,
|
||||
hasError: _hasError,
|
||||
onPlay: _onPlayPressed,
|
||||
onRetry: _onPlayPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
|
||||
if (_isReady) return player;
|
||||
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FeedVideoPreviewPlaceholder extends StatelessWidget {
|
||||
const FeedVideoPreviewPlaceholder({
|
||||
super.key,
|
||||
required this.previewUrl,
|
||||
required this.isLoading,
|
||||
required this.hasError,
|
||||
required this.onPlay,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String? previewUrl;
|
||||
final bool isLoading;
|
||||
final bool hasError;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
if (previewUrl != null && previewUrl!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: previewUrl!,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||
)
|
||||
else
|
||||
const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||
if (hasError)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isLoading)
|
||||
const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||
else
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: onPlay,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
@@ -13,12 +13,9 @@ class FeedRepository {
|
||||
|
||||
FeedRepository({required this.feedService});
|
||||
|
||||
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
|
||||
final response = await feedService.getFeed(search: search, cursor: cursor);
|
||||
return (
|
||||
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
|
||||
nextCursor: response.meta.nextCursor,
|
||||
);
|
||||
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
|
||||
return (await feedService.getBlogPostList(page: page, query: query));
|
||||
|
||||
}
|
||||
|
||||
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
@@ -19,13 +18,7 @@ abstract class FeedService {
|
||||
@GET('/api/blogPosts/index')
|
||||
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
||||
|
||||
@GET('/api/feed')
|
||||
Future<PostFeedPaginationModel> getFeed({
|
||||
@Query('search') String? search,
|
||||
@Query('cursor') String? cursor,
|
||||
});
|
||||
|
||||
@GET('/api/v2/blogPosts/show/{postId}')
|
||||
@GET('/api/blogPosts/show/{postId}')
|
||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||
|
||||
@GET('/api/recipes/index')
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -33,213 +31,221 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
||||
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
late bool isLiked;
|
||||
late int totalLikes;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||
bloc: widget.profileCubit,
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
if (state is! ProfileStateLoaded) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final FeedBlogPostModel post = state.blogPosts[widget.index];
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
post.author.authorProfileIcon.contains('https')
|
||||
? post.author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + post.author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
feedBlogPostModel: post,
|
||||
userSlug: post.author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty) ...[
|
||||
if (state is ProfileStateLoaded) {
|
||||
isLiked = state.blogPosts[widget.index].wasLikedByMe;
|
||||
totalLikes = state.blogPosts[widget.index].totalLikes;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
post.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
|
||||
if (post.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: _truncateDescription(post.description),
|
||||
fontSize: 16,
|
||||
textColor: Colors.black87,
|
||||
),
|
||||
),
|
||||
if (post.description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
onOpenGallery: _openGallery,
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
isLiked ? await context.read<FeedCubit>().likePost(post.id) : await context.read<FeedCubit>().unlikedPost(post.id);
|
||||
widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
|
||||
? state.blogPosts[widget.index].author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
text: post.totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 20,
|
||||
width: 20,
|
||||
'assets/icons/share.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.blogPosts[widget.index].author.authorName,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
|
||||
id: state.blogPosts[widget.index].id,
|
||||
feedBlogPostModel: state.blogPosts[widget.index],
|
||||
userSlug: state.blogPosts[widget.index].author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
state.blogPosts[widget.index].title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
|
||||
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
||||
if (state.blogPosts[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.blogPosts[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
fontSize: 16,
|
||||
textColor: Colors.black87,
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
post.totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
if (state.blogPosts[widget.index].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.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
isLiked
|
||||
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
|
||||
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
|
||||
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
text: state.blogPosts[widget.index].totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 20,
|
||||
width: 20,
|
||||
'assets/icons/share.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
state.blogPosts[widget.index].totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _truncateDescription(String description) {
|
||||
final isLong = description.length > 200;
|
||||
if (!isLong) return description;
|
||||
|
||||
var cutPoint = 200;
|
||||
final preview = description.substring(0, cutPoint);
|
||||
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
final hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
if (hasUnclosedBold) {
|
||||
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
final boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
return description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
@@ -238,7 +238,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed('becomeConsultantScreen'),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'becomeConsultantScreen',
|
||||
extra: {'slug': state.profile.slug},
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.36+125
|
||||
version: 1.3.35+124
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -82,7 +82,6 @@ dependencies:
|
||||
rxdart: ^0.27.7
|
||||
gpassword: ^1.0.0
|
||||
webview_flutter_platform_interface: ^2.14.0
|
||||
flutter_inappwebview: ^6.1.5
|
||||
intl_utils: ^2.8.13
|
||||
sentry_dio: ^9.10.0
|
||||
livekit_client: ^2.6.1
|
||||
|
||||
@@ -7,32 +7,6 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
group('AppRouter', () {
|
||||
group('resolveDeepLinkTarget - blog_post', () {
|
||||
test('ru/blog_post/{id} → /blog-post-screen?id=', () {
|
||||
final target = AppRouter.resolveDeepLinkTarget(
|
||||
Uri.parse('https://eatvio.ru/ru/blog_post/123'),
|
||||
);
|
||||
|
||||
expect(target, '/blog-post-screen?id=123');
|
||||
});
|
||||
|
||||
test('en/blog_post/{id} → /blog-post-screen?id=', () {
|
||||
final target = AppRouter.resolveDeepLinkTarget(
|
||||
Uri.parse('https://eatvio.com/en/blog_post/456'),
|
||||
);
|
||||
|
||||
expect(target, '/blog-post-screen?id=456');
|
||||
});
|
||||
|
||||
test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () {
|
||||
final target = AppRouter.resolveDeepLinkTarget(
|
||||
Uri.parse('https://eatvio.com/blog_post/789'),
|
||||
);
|
||||
|
||||
expect(target, '/blog-post-screen?id=789');
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveDeepLinkTarget - finance/alfa/return', () {
|
||||
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
|
||||
|
||||
|
||||
@@ -51,11 +51,8 @@ void main() {
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
) as GoRoute;
|
||||
);
|
||||
expect(blogPostRoute, isA<GoRoute>());
|
||||
expect(blogPostRoute.path, '/blog-post-screen');
|
||||
expect(blogPostRoute.name, 'blogPostScreen');
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
class MockFeedRepository extends Mock implements FeedRepository {}
|
||||
|
||||
void main() {
|
||||
@@ -31,7 +29,21 @@ void main() {
|
||||
|
||||
test('getBlogPost - успешная загрузка поста', () async {
|
||||
const postId = 1;
|
||||
final mockPost = FeedFixtures.feedBlogPostModel();
|
||||
final mockPost = FeedBlogPostModel(
|
||||
id: postId,
|
||||
title: 'Test Post',
|
||||
state: 'active',
|
||||
image: '',
|
||||
description: 'Test Content',
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false),
|
||||
);
|
||||
|
||||
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
|
||||
|
||||
@@ -39,8 +51,6 @@ void main() {
|
||||
|
||||
expect(cubit.state.isLoading, false);
|
||||
expect(cubit.state.blogPostModel, mockPost);
|
||||
expect(cubit.state.blogPostModel?.images, hasLength(2));
|
||||
expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
verify(() => mockRepository.getBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.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/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -20,7 +21,6 @@ import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
import '../../../../fixtures/feed_fixtures.dart';
|
||||
import 'blog_post_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
|
||||
@@ -167,7 +167,21 @@ void main() {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||
final blogPostModel = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post Title',
|
||||
description: 'Test post description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
@@ -188,8 +202,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostScreen), findsOneWidget);
|
||||
expect(find.text('Заголовок'), findsOneWidget);
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Test Post Title'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +211,21 @@ void main() {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||
final blogPostModel = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post Title',
|
||||
description: 'Test post description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
@@ -221,104 +248,11 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final likeIcon = find.byIcon(Icons.favorite_border_rounded);
|
||||
await tester.ensureVisible(likeIcon);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(likeIcon);
|
||||
await tester.tap(find.byIcon(Icons.favorite_border_rounded));
|
||||
await tester.pump();
|
||||
|
||||
verify(blogPostCubit.toggleLike()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('renders gallery from fixture', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostWithGallery();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(blogPostCubit, commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Галерея'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('renders video-only post', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostVideoOnly();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(blogPostCubit, commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Видео'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('renders gallery and video post', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(blogPostCubit, commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Галерея и видео'), findsWidgets);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,4 +33,3 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -100,4 +100,3 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = CertificatesModel(title: 'Certificate Title', date: '2024-01-15');
|
||||
const model = CertificatesModel(
|
||||
title: 'Certificate Title',
|
||||
date: '2024-01-15',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -105,8 +108,21 @@ void main() {
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
|
||||
const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01');
|
||||
const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]);
|
||||
const certificates = CertificatesModel(
|
||||
title: 'Course 1',
|
||||
date: '2024-01-01',
|
||||
);
|
||||
const model = ConsultantProfileModel(
|
||||
hasMedicalEducation: true,
|
||||
socialLinks: socialLinks,
|
||||
photos: ['photo1.jpg'],
|
||||
certificates: [certificates],
|
||||
legalType: 'individualPerson',
|
||||
inn: '123456789012',
|
||||
snils: '123-456-789 00',
|
||||
agreementAccepted: true,
|
||||
frdoAgreementAccepted: true,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -114,6 +130,34 @@ void main() {
|
||||
expect(json['social_links'], isNotNull);
|
||||
expect(json['cert_photos'], ['photo1.jpg']);
|
||||
expect(json['completed_courses'], isNotNull);
|
||||
expect(json['legal_type'], 'individualPerson');
|
||||
expect(json['inn'], '123456789012');
|
||||
expect(json['snils'], '123-456-789 00');
|
||||
expect(json['agreement_accepted'], isTrue);
|
||||
expect(json['frdo_agreement_accepted'], isTrue);
|
||||
});
|
||||
|
||||
test('hasValidPassportData validates required individualPerson fields', () {
|
||||
const valid = ConsultantProfileModel(
|
||||
lastName: 'Иванов',
|
||||
firstName: 'Иван',
|
||||
inn: '123456789012',
|
||||
snils: '123-456-789 00',
|
||||
passportSeries: '1234',
|
||||
passportNumber: '567890',
|
||||
passportIssueDate: '2020-01-15',
|
||||
passportIssuer: 'ОВД',
|
||||
passportIssuerCode: '772-053',
|
||||
birthDate: '1990-05-20',
|
||||
birthPlace: 'Москва',
|
||||
registrationAddress: 'Адрес 1',
|
||||
residenceAddress: 'Адрес 2',
|
||||
citizenship: 'RU',
|
||||
);
|
||||
|
||||
expect(valid.hasValidPassportData, isTrue);
|
||||
expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse);
|
||||
expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -46,7 +47,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('fromJson handles null additional field', () {
|
||||
final json = {'title': 'Test title', 'status': 'canceled', 'additional': null};
|
||||
final json = {
|
||||
'title': 'Test title',
|
||||
'status': 'canceled',
|
||||
'additional': null,
|
||||
};
|
||||
|
||||
final model = CanceledModerationModel.fromJson(json);
|
||||
|
||||
@@ -56,7 +61,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = CanceledModerationModel(title: 'Test title', status: ModerationStatus.canceled, additional: ['reason1', 'reason2']);
|
||||
const model = CanceledModerationModel(
|
||||
title: 'Test title',
|
||||
status: ModerationStatus.canceled,
|
||||
additional: ['reason1', 'reason2'],
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -122,10 +131,67 @@ void main() {
|
||||
expect(model.canceledModeration!.status, ModerationStatus.canceled);
|
||||
});
|
||||
|
||||
test('fromJson parses legal_entity_resource', () {
|
||||
final json = {
|
||||
'rating_place': 5,
|
||||
'education_resource': {'request_state': 'approved'},
|
||||
'cert_photos': [],
|
||||
'legal_entity_resource': {
|
||||
'id': 1,
|
||||
'type': 'individualPerson',
|
||||
'inn': '123456789012',
|
||||
'snils': '123-456-789 00',
|
||||
'state': 'created',
|
||||
'alfa_scoring_status': 'scoring_failed',
|
||||
'alfa_scoring_request_id': 'abc',
|
||||
'passport_series': '1234',
|
||||
'passport_number': '567890',
|
||||
'passport_issue_date': '2020-01-15T00:00:00.000000Z',
|
||||
'passport_issuer': 'ОВД',
|
||||
'passport_issuer_code': '772-053',
|
||||
'birth_date': '1990-05-20',
|
||||
'birth_place': 'Москва',
|
||||
'registration_address': 'Адрес 1',
|
||||
'residence_address': 'Адрес 2',
|
||||
},
|
||||
};
|
||||
|
||||
final model = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
expect(model.legalEntity, isNotNull);
|
||||
expect(model.legalEntity!.type, 'individualPerson');
|
||||
expect(model.legalEntity!.inn, '123456789012');
|
||||
expect(model.legalEntity!.passportSeries, '1234');
|
||||
expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15');
|
||||
expect(
|
||||
model.legalEntity!.alfaScoringStatus,
|
||||
AlfaScoringStatus.scoringFailed,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeApiDate strips time from DateTime.toString format', () {
|
||||
expect(
|
||||
LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'),
|
||||
'2023-09-22',
|
||||
);
|
||||
expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22');
|
||||
expect(LegalEntityModel.normalizeApiDate(null), isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes ConsultationRequestModel correctly', () {
|
||||
const educationModel = EducationModel(requestState: ModerationStatus.approved);
|
||||
const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')];
|
||||
const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null);
|
||||
const educationModel = EducationModel(
|
||||
requestState: ModerationStatus.approved,
|
||||
);
|
||||
const photos = [
|
||||
CertPhotosModel(url: 'https://example.com/photo1.jpg'),
|
||||
CertPhotosModel(url: 'https://example.com/photo2.jpg'),
|
||||
];
|
||||
const model = ConsultationRequestModel(
|
||||
ratingPlace: 5,
|
||||
educationModel: educationModel,
|
||||
photos: photos,
|
||||
canceledModeration: null,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ void main() {
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
requestModel: null,
|
||||
isUpdating: false
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
expect(state.consultant, consultant);
|
||||
@@ -20,6 +20,8 @@ void main() {
|
||||
expect(state.loading, false);
|
||||
expect(state.requestModel, isNull);
|
||||
expect(state.certPhotos, isEmpty);
|
||||
expect(state.awaitingScoringOutcome, isFalse);
|
||||
expect(state.fieldErrors, isEmpty);
|
||||
});
|
||||
|
||||
test('creates instance with custom values', () {
|
||||
@@ -38,7 +40,7 @@ void main() {
|
||||
loading: true,
|
||||
requestModel: null,
|
||||
certPhotos: [certPhoto],
|
||||
isUpdating: false
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
expect(state.acceptOffer, true);
|
||||
@@ -54,7 +56,7 @@ void main() {
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
requestModel: null,
|
||||
isUpdating: false
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
final updatedState = originalState.copyWith(
|
||||
@@ -92,7 +94,7 @@ void main() {
|
||||
loading: false,
|
||||
requestModel: null,
|
||||
certPhotos: [certPhoto1],
|
||||
isUpdating: false
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
final updatedState = originalState.copyWith(
|
||||
@@ -100,9 +102,14 @@ void main() {
|
||||
);
|
||||
|
||||
expect(updatedState.certPhotos.length, 2);
|
||||
expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg');
|
||||
expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg');
|
||||
expect(
|
||||
updatedState.certPhotos.first.url,
|
||||
'https://example.com/photo1.jpg',
|
||||
);
|
||||
expect(
|
||||
updatedState.certPhotos.last.url,
|
||||
'https://example.com/photo2.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -23,16 +26,50 @@ void main() {
|
||||
late RegisterConsultantCubit cubit;
|
||||
late ConsultationRequestModel requestModel;
|
||||
late List<ConsultantCertPhotoModel> certPhotosFromFixture;
|
||||
late List<SbpBankModel> sbpBanksFromFixture;
|
||||
late ConsultantProfileModel consultantProfile;
|
||||
|
||||
ConsultationRequestModel requestWithScoring(String scoringStatus) {
|
||||
final json = FixtureReader.readAsMap(
|
||||
'consultant/consultation_request.json',
|
||||
);
|
||||
final legal = Map<String, dynamic>.from(
|
||||
json['legal_entity_resource'] as Map,
|
||||
);
|
||||
legal['alfa_scoring_status'] = scoringStatus;
|
||||
json['legal_entity_resource'] = legal;
|
||||
return ConsultationRequestModel.fromJson(json);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
repository = MockConsultantRepository();
|
||||
cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json'));
|
||||
certPhotosFromFixture = FixtureReader.readAsList(
|
||||
'consultant/consultant_cert_photos.json',
|
||||
).map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>)).toList();
|
||||
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json'));
|
||||
cubit = RegisterConsultantCubit(
|
||||
consultantRepository: repository,
|
||||
scoringPollInterval: const Duration(milliseconds: 30),
|
||||
);
|
||||
requestModel = ConsultationRequestModel.fromJson(
|
||||
FixtureReader.readAsMap('consultant/consultation_request.json'),
|
||||
);
|
||||
certPhotosFromFixture =
|
||||
FixtureReader.readAsList('consultant/consultant_cert_photos.json')
|
||||
.map(
|
||||
(item) => ConsultantCertPhotoModel.fromJson(
|
||||
item as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
sbpBanksFromFixture =
|
||||
FixtureReader.readAsList('consultant/sbp_banks.json')
|
||||
.map(
|
||||
(item) => SbpBankModel.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
consultantProfile = ConsultantProfileModel.fromJson(
|
||||
FixtureReader.readAsMap('consultant/consultant_profile_request.json'),
|
||||
);
|
||||
when(
|
||||
() => repository.getSbpBanks(),
|
||||
).thenAnswer((_) async => sbpBanksFromFixture);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -44,11 +81,16 @@ void main() {
|
||||
expect(cubit.state.consultant.hasMedicalEducation, isTrue);
|
||||
expect(cubit.state.acceptOffer, isFalse);
|
||||
expect(cubit.state.requestModel, isNull);
|
||||
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none);
|
||||
});
|
||||
|
||||
test('getMyRequestStatus hydrates state with backend data', () async {
|
||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestModel);
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
|
||||
@@ -56,53 +98,308 @@ void main() {
|
||||
expect(state.loading, isFalse);
|
||||
expect(state.requestModel, equals(requestModel));
|
||||
expect(state.consultant.certificates, isNotNull);
|
||||
// Certificates list is built from backend courses; we just ensure it is not empty.
|
||||
expect(state.consultant.certificates!.length, greaterThan(0));
|
||||
expect(state.consultant.certificates!.first.title, 'Нутрициология PRO');
|
||||
expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized');
|
||||
expect(
|
||||
state.consultant.socialLinks?.instaLink,
|
||||
isNull,
|
||||
reason: 'empty links are sanitized',
|
||||
);
|
||||
expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant');
|
||||
// All cert photos from backend are kept in state (visible + hidden).
|
||||
expect(state.consultant.legalType, 'individualPerson');
|
||||
expect(state.consultant.inn, '123456789012');
|
||||
expect(state.consultant.passportSeries, '1234');
|
||||
expect(state.consultant.passportIssueDate, '2020-01-15');
|
||||
expect(state.consultant.birthPlace, 'г. Москва');
|
||||
expect(state.certPhotos, hasLength(3));
|
||||
expect(state.certPhotos.map((e) => e.id), containsAll(<int>[101, 102, 103]));
|
||||
verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1);
|
||||
expect(
|
||||
state.certPhotos.map((e) => e.id),
|
||||
containsAll(<int>[101, 102, 103]),
|
||||
);
|
||||
expect(state.sbpBanks, hasLength(2));
|
||||
expect(state.sbpBanks.first.bankName, 'Сбербанк');
|
||||
expect(state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||
expect(state.alfaScoringStatus.canRetry, isTrue);
|
||||
expect(state.isScoringPolling, isFalse);
|
||||
expect(
|
||||
state.awaitingScoringOutcome,
|
||||
isFalse,
|
||||
reason: 'hydrate must not arm success sheet',
|
||||
);
|
||||
verify(
|
||||
() => repository.getMyRequestStatus('my-consultant-slug'),
|
||||
).called(1);
|
||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||
verify(() => repository.getSbpBanks()).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'getMyRequestStatus with processing status starts scoring poll',
|
||||
() async {
|
||||
final pending = requestWithScoring('scoring_pending');
|
||||
final complete = requestWithScoring('scoring_complete');
|
||||
var calls = 0;
|
||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((
|
||||
_,
|
||||
) async {
|
||||
calls++;
|
||||
return calls == 1 ? pending : complete;
|
||||
});
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
|
||||
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringPending);
|
||||
expect(cubit.state.isScoringPolling, isTrue);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.scoringComplete,
|
||||
);
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('disableLoading stops spinner and loads cert photos', () async {
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
|
||||
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||
|
||||
expect(cubit.state.loading, isFalse);
|
||||
expect(cubit.state.certPhotos, hasLength(3));
|
||||
expect(cubit.state.sbpBanks, hasLength(2));
|
||||
expect(cubit.userSlug, 'my-consultant-slug');
|
||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||
verify(() => repository.getSbpBanks()).called(1);
|
||||
});
|
||||
|
||||
test('searchSbpBanks filters banks by name', () async {
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
await cubit.disableLoading();
|
||||
|
||||
expect(cubit.state.loading, isFalse);
|
||||
// disableLoading now keeps all photos from repository.
|
||||
expect(cubit.state.certPhotos, hasLength(3));
|
||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||
final results = cubit.searchSbpBanks('сбер');
|
||||
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.bankMemberId, '100000000111');
|
||||
});
|
||||
|
||||
test('updateConsultantProfileModel replaces consultant data and offer flag', () {
|
||||
cubit.updateConsultantProfileModel(consultantProfile, true);
|
||||
|
||||
expect(cubit.state.consultant, consultantProfile);
|
||||
expect(cubit.state.acceptOffer, isTrue);
|
||||
expect(cubit.state.loading, isFalse);
|
||||
});
|
||||
|
||||
test('registerConsultant forwards payload with update=false by default', () async {
|
||||
test('findSbpBankById returns matching bank', () async {
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
await cubit.disableLoading();
|
||||
|
||||
await cubit.registerConsultant();
|
||||
final bank = cubit.findSbpBankById('100000000004');
|
||||
|
||||
verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1);
|
||||
expect(bank?.bankName, 'Т-Банк');
|
||||
});
|
||||
|
||||
test('registerConsultant sends update=true after getMyRequestStatus call', () async {
|
||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
test(
|
||||
'updateConsultantProfileModel replaces consultant data and offer flag',
|
||||
() {
|
||||
cubit.updateConsultantProfileModel(consultantProfile, true);
|
||||
|
||||
expect(cubit.state.consultant, consultantProfile);
|
||||
expect(cubit.state.acceptOffer, isTrue);
|
||||
expect(cubit.state.loading, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'registerConsultant forwards payload with update=false by default',
|
||||
() async {
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
cubit.updateConsultantProfileModel(consultantProfile);
|
||||
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||
await cubit.registerConsultant();
|
||||
|
||||
final captured =
|
||||
verify(
|
||||
() => repository.registerConsultant(
|
||||
consultant: captureAny<ConsultantProfileModel>(
|
||||
named: 'consultant',
|
||||
),
|
||||
update: false,
|
||||
),
|
||||
).captured.single
|
||||
as ConsultantProfileModel;
|
||||
|
||||
expect(captured.legalType, 'individualPerson');
|
||||
expect(captured.agreementAccepted, isTrue);
|
||||
expect(captured.frdoAgreementAccepted, isTrue);
|
||||
expect(captured.inn, isNotNull);
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.beneficiaryCreating,
|
||||
);
|
||||
expect(
|
||||
cubit.state.isScoringPolling,
|
||||
isFalse,
|
||||
reason: 'no slug for poll',
|
||||
);
|
||||
expect(
|
||||
cubit.state.awaitingScoringOutcome,
|
||||
isFalse,
|
||||
reason: 'cannot await scoring without slug',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'getMyRequestStatus with scoring_complete does not arm awaiting flag',
|
||||
() async {
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.scoringComplete,
|
||||
);
|
||||
expect(cubit.state.awaitingScoringOutcome, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'registerConsultant with slug starts scoring poll after submit',
|
||||
() async {
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||
cubit.updateConsultantProfileModel(consultantProfile);
|
||||
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||
await cubit.registerConsultant();
|
||||
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.beneficiaryCreating,
|
||||
);
|
||||
expect(cubit.state.isScoringPolling, isTrue);
|
||||
expect(cubit.state.awaitingScoringOutcome, isTrue);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.scoringComplete,
|
||||
);
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
expect(
|
||||
cubit.state.awaitingScoringOutcome,
|
||||
isFalse,
|
||||
reason: 'flag clears on terminal scoring for one-shot success sheet',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'scoring poll failed clears awaiting flag without leaving processing',
|
||||
() async {
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestWithScoring('scoring_failed'));
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||
cubit.updateConsultantProfileModel(consultantProfile);
|
||||
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||
await cubit.registerConsultant();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
expect(cubit.state.awaitingScoringOutcome, isFalse);
|
||||
expect(cubit.state.alfaScoringStatus.canRetry, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'scoring poll keeps processing UI while API still returns none',
|
||||
() async {
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
var calls = 0;
|
||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((
|
||||
_,
|
||||
) async {
|
||||
calls++;
|
||||
if (calls <= 2) {
|
||||
return requestWithScoring('none');
|
||||
}
|
||||
return requestWithScoring('scoring_complete');
|
||||
});
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||
cubit.updateConsultantProfileModel(consultantProfile);
|
||||
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||
await cubit.registerConsultant();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.beneficiaryCreating,
|
||||
);
|
||||
expect(cubit.state.isScoringPolling, isTrue);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
expect(
|
||||
cubit.state.alfaScoringStatus,
|
||||
AlfaScoringStatus.scoringComplete,
|
||||
);
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('registerConsultant sends legal payload with update=true', () async {
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestModel);
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
@@ -113,21 +410,131 @@ void main() {
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
await cubit.registerConsultant();
|
||||
|
||||
verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1);
|
||||
final captured =
|
||||
verify(
|
||||
() => repository.registerConsultant(
|
||||
consultant: captureAny<ConsultantProfileModel>(
|
||||
named: 'consultant',
|
||||
),
|
||||
update: true,
|
||||
),
|
||||
).captured.single
|
||||
as ConsultantProfileModel;
|
||||
|
||||
final json = captured.toJson();
|
||||
expect(json['legal_type'], 'individualPerson');
|
||||
expect(json['inn'], '123456789012');
|
||||
expect(json['passport_series'], '1234');
|
||||
expect(json.containsKey('agreement_accepted'), isFalse);
|
||||
expect(json.containsKey('frdo_agreement_accepted'), isFalse);
|
||||
});
|
||||
|
||||
test('retryScoring calls update and restarts poll', () async {
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestModel);
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
|
||||
await cubit.retryScoring();
|
||||
|
||||
verify(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: true,
|
||||
),
|
||||
).called(1);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringComplete);
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
});
|
||||
|
||||
test('registerConsultant blocks submit without passport data', () async {
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.registerConsultant();
|
||||
|
||||
expect(cubit.state.error, 'Заполните паспортные данные');
|
||||
verifyNever(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('registerConsultant maps Laravel 422 into fieldErrors', () async {
|
||||
when(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
update: any<bool>(named: 'update'),
|
||||
),
|
||||
).thenThrow(
|
||||
DioException(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/v2/consultant_request/registration',
|
||||
),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/v2/consultant_request/registration',
|
||||
),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': 'The given data was invalid.',
|
||||
'errors': {
|
||||
'inn': ['The inn format is invalid.'],
|
||||
},
|
||||
},
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
|
||||
cubit.updateConsultantProfileModel(consultantProfile);
|
||||
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||
await cubit.registerConsultant();
|
||||
|
||||
expect(cubit.state.fieldErrors['inn'], 'Некорректный ИНН');
|
||||
expect(cubit.state.error, contains('Некорректный ИНН'));
|
||||
expect(cubit.state.awaitingScoringOutcome, isFalse);
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
});
|
||||
|
||||
test('removeCertPhoto marks cert as deleted locally', () async {
|
||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(() => repository.hideConsultantCertPhoto(any<int>())).thenAnswer((_) async {});
|
||||
when(
|
||||
() => repository.getMyRequestStatus(any<String>()),
|
||||
).thenAnswer((_) async => requestModel);
|
||||
when(
|
||||
() => repository.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotosFromFixture);
|
||||
when(
|
||||
() => repository.hideConsultantCertPhoto(any<int>()),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||
// All photos from backend are present in state.
|
||||
expect(cubit.state.certPhotos, hasLength(3));
|
||||
|
||||
await cubit.removeCertPhoto(101, null);
|
||||
|
||||
// Current implementation only tracks deleted IDs; photos list remains unchanged here.
|
||||
verifyNever(() => repository.hideConsultantCertPhoto(any<int>()));
|
||||
expect(cubit.state.deletedPhotoIds, contains(101));
|
||||
expect(cubit.state.certPhotos, hasLength(3));
|
||||
@@ -136,7 +543,9 @@ void main() {
|
||||
test('uploadCertPhotos - не загружает если список пуст', () async {
|
||||
await cubit.addCertPhotos([]);
|
||||
|
||||
verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files')));
|
||||
verifyNever(
|
||||
() => repository.storeConsultantCertPhotos(files: any(named: 'files')),
|
||||
);
|
||||
});
|
||||
|
||||
test('updateConsultantProfileModel - обновляет без acceptOffer', () {
|
||||
@@ -147,4 +556,38 @@ void main() {
|
||||
expect(cubit.state.loading, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AlfaScoringStatus', () {
|
||||
test('fromString maps known values', () {
|
||||
expect(
|
||||
AlfaScoringStatus.fromString('beneficiary_creating'),
|
||||
AlfaScoringStatus.beneficiaryCreating,
|
||||
);
|
||||
expect(
|
||||
AlfaScoringStatus.fromString('scoring_pending'),
|
||||
AlfaScoringStatus.scoringPending,
|
||||
);
|
||||
expect(
|
||||
AlfaScoringStatus.fromString('scoring_complete'),
|
||||
AlfaScoringStatus.scoringComplete,
|
||||
);
|
||||
expect(
|
||||
AlfaScoringStatus.fromString('scoring_failed'),
|
||||
AlfaScoringStatus.scoringFailed,
|
||||
);
|
||||
expect(
|
||||
AlfaScoringStatus.fromString('beneficiary_failed'),
|
||||
AlfaScoringStatus.beneficiaryFailed,
|
||||
);
|
||||
expect(AlfaScoringStatus.fromString(null), AlfaScoringStatus.none);
|
||||
expect(AlfaScoringStatus.fromString('unknown'), AlfaScoringStatus.none);
|
||||
});
|
||||
|
||||
test('helpers', () {
|
||||
expect(AlfaScoringStatus.scoringPending.isProcessing, isTrue);
|
||||
expect(AlfaScoringStatus.scoringFailed.canRetry, isTrue);
|
||||
expect(AlfaScoringStatus.scoringComplete.isComplete, isTrue);
|
||||
expect(AlfaScoringStatus.scoringComplete.isTerminal, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,15 +18,28 @@ void main() {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child),
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders additional settings screen', (tester) async {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: null,
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
|
||||
@@ -21,12 +21,18 @@ import 'certificates_and_diplomas_screen_smoke_test.mocks.dart';
|
||||
void main() {
|
||||
group('CertificatesAndDiplomasScreen smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -45,15 +51,30 @@ void main() {
|
||||
|
||||
testWidgets('renders certificates and diplomas screen', (tester) async {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: null,
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
|
||||
|
||||
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen())));
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: wrap(const CertificatesAndDiplomasScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
@@ -67,16 +88,28 @@ void main() {
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: null,
|
||||
photos: null,
|
||||
certificates: [CertificatesModel(title: 'Test Certificate', date: '2025-01-01')],
|
||||
certificates: [
|
||||
CertificatesModel(title: 'Test Certificate', date: '2025-01-01'),
|
||||
],
|
||||
);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
|
||||
|
||||
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen())));
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: wrap(const CertificatesAndDiplomasScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
|
||||
@@ -19,43 +19,88 @@ import 'last_step_screen_test.mocks.dart';
|
||||
void main() {
|
||||
group('LastStepScreen smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child, RegisterConsultantCubit cubit) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child),
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders last step screen', (tester) async {
|
||||
testWidgets(
|
||||
'renders last step screen with both agreements for new registration',
|
||||
(tester) async {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(wrap(const LastStepScreen(), cubit));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LastStepScreen), findsOneWidget);
|
||||
expect(find.text('Последний шаг'), findsOneWidget);
|
||||
expect(find.byType(CheckboxListTile), findsNWidgets(2));
|
||||
expect(find.textContaining('ФРДО'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('hides FRDO checkbox in update mode', (tester) async {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
const consultant = ConsultantProfileModel();
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: true,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateConsultantProfileModel(any, any)).thenReturn(null);
|
||||
|
||||
await tester.pumpWidget(wrap(const LastStepScreen(), cubit));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LastStepScreen), findsOneWidget);
|
||||
expect(find.text('Последний шаг'), findsOneWidget);
|
||||
expect(find.byType(CheckboxListTile), findsOneWidget);
|
||||
expect(find.textContaining('ФРДО'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockConsultantRepository extends Mock implements ConsultantRepository {}
|
||||
|
||||
void main() {
|
||||
group('PassportDataScreen smoke', () {
|
||||
late RegisterConsultantCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
cubit = RegisterConsultantCubit(
|
||||
consultantRepository: MockConsultantRepository(),
|
||||
);
|
||||
cubit.emit(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()),
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders passport data fields', (tester) async {
|
||||
await tester.pumpWidget(wrap(const PassportDataScreen()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PassportDataScreen), findsOneWidget);
|
||||
expect(find.text('Паспортные данные'), findsOneWidget);
|
||||
expect(find.text('Фамилия'), findsOneWidget);
|
||||
expect(find.text('Имя'), findsOneWidget);
|
||||
expect(find.text('ИНН (12 цифр)'), findsOneWidget);
|
||||
expect(find.text('СНИЛС'), findsOneWidget);
|
||||
expect(find.text('Гражданство (ISO, например RU)'), findsOneWidget);
|
||||
expect(find.text('Телефон для выплат через СБП'), findsOneWidget);
|
||||
expect(find.text('Банк получатель (поиск)'), findsOneWidget);
|
||||
expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,12 +19,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart';
|
||||
void main() {
|
||||
group('PhotoOrScansScreen smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child, RegisterConsultantCubit cubit) {
|
||||
@@ -38,7 +44,10 @@ void main() {
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: child),
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -49,11 +58,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
@@ -61,7 +81,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PhotoOrScansScreen), findsOneWidget);
|
||||
expect(find.text('Загрузите фото или сканы дипломов и сертификатов'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Загрузите фото или сканы дипломов и сертификатов'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,11 +94,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockConsultantRepository extends Mock implements ConsultantRepository {}
|
||||
|
||||
void main() {
|
||||
group('RegistrationAsScreen smoke', () {
|
||||
late RegisterConsultantCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
cubit = RegisterConsultantCubit(
|
||||
consultantRepository: MockConsultantRepository(),
|
||||
);
|
||||
cubit.emit(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()),
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(body: child),
|
||||
home: Scaffold(
|
||||
body: BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders registration as screen', (tester) async {
|
||||
await tester.pumpWidget(wrap(const RegistrationAsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(RegistrationAsScreen), findsOneWidget);
|
||||
expect(find.text('Регистрация в качестве'), findsOneWidget);
|
||||
expect(find.text('Физического лица'), findsOneWidget);
|
||||
expect(cubit.state.consultant.legalType, 'individualPerson');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,12 +21,18 @@ import 'social_networks_screen_smoke_test.mocks.dart';
|
||||
void main() {
|
||||
group('SocialNetworksScreen smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -47,18 +53,34 @@ void main() {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
|
||||
|
||||
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const SocialNetworksScreen())));
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: wrap(const SocialNetworksScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
@@ -69,18 +91,34 @@ void main() {
|
||||
final cubit = MockRegisterConsultantCubit();
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: 'https://vk.com/test', tgLink: 'https://t.me/test', fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: 'https://vk.com/test',
|
||||
tgLink: 'https://t.me/test',
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateConsultantProfileModel(any)).thenReturn(null);
|
||||
|
||||
await tester.pumpWidget(BlocProvider<RegisterConsultantCubit>.value(value: cubit, child: wrap(const SocialNetworksScreen())));
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<RegisterConsultantCubit>.value(
|
||||
value: cubit,
|
||||
child: wrap(const SocialNetworksScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
|
||||
@@ -21,12 +21,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart';
|
||||
void main() {
|
||||
group('StepperBecomeConsultantScreen smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -48,11 +54,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
@@ -71,11 +88,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
@@ -86,7 +114,7 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('шаг 1 из 6'), findsOneWidget);
|
||||
expect(find.text('шаг 1 из 7'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays progress bar', (tester) async {
|
||||
@@ -94,11 +122,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
@@ -117,11 +156,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
@@ -140,11 +190,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
@@ -163,11 +224,22 @@ void main() {
|
||||
final cubit = RegisterConsultantCubit(consultantRepository: repository);
|
||||
const consultant = ConsultantProfileModel(
|
||||
hasMedicalEducation: null,
|
||||
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null),
|
||||
socialLinks: SocialLinksModel(
|
||||
vkLink: null,
|
||||
tgLink: null,
|
||||
fbLink: null,
|
||||
instaLink: null,
|
||||
personalSiteLink: null,
|
||||
),
|
||||
photos: null,
|
||||
certificates: null,
|
||||
);
|
||||
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
isUpdating: false,
|
||||
);
|
||||
|
||||
cubit.emit(state);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -36,42 +37,99 @@ void main() {
|
||||
setUp(() {
|
||||
mockService = MockConsultantService();
|
||||
repository = ConsultantRepository(consultantService: mockService);
|
||||
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json'));
|
||||
consultationRequest = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json'));
|
||||
certPhotos = FixtureReader.readAsList('consultant/consultant_cert_photos.json')
|
||||
.map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
consultantProfile = ConsultantProfileModel.fromJson(
|
||||
FixtureReader.readAsMap('consultant/consultant_profile_request.json'),
|
||||
);
|
||||
consultationRequest = ConsultationRequestModel.fromJson(
|
||||
FixtureReader.readAsMap('consultant/consultation_request.json'),
|
||||
);
|
||||
certPhotos =
|
||||
FixtureReader.readAsList('consultant/consultant_cert_photos.json')
|
||||
.map(
|
||||
(item) => ConsultantCertPhotoModel.fromJson(
|
||||
item as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
|
||||
test('registerConsultant delegates to consultantRegistration when update=false', () async {
|
||||
when(() => mockService.consultantRegistration(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))).thenAnswer((_) async {});
|
||||
test(
|
||||
'registerConsultant delegates to consultantRegistration when update=false',
|
||||
() async {
|
||||
when(
|
||||
() => mockService.consultantRegistration(
|
||||
consultantModel: any<ConsultantProfileModel>(
|
||||
named: 'consultantModel',
|
||||
),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await repository.registerConsultant(consultant: consultantProfile);
|
||||
await repository.registerConsultant(consultant: consultantProfile);
|
||||
|
||||
verify(() => mockService.consultantRegistration(consultantModel: consultantProfile)).called(1);
|
||||
verifyNever(() => mockService.consultantUpdate(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel')));
|
||||
});
|
||||
verify(
|
||||
() => mockService.consultantRegistration(
|
||||
consultantModel: consultantProfile,
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockService.consultantUpdate(
|
||||
consultantModel: any<ConsultantProfileModel>(
|
||||
named: 'consultantModel',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('registerConsultant delegates to consultantUpdate when update=true', () async {
|
||||
when(() => mockService.consultantUpdate(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))).thenAnswer((_) async {});
|
||||
test(
|
||||
'registerConsultant delegates to consultantUpdate when update=true',
|
||||
() async {
|
||||
when(
|
||||
() => mockService.consultantUpdate(
|
||||
consultantModel: any<ConsultantProfileModel>(
|
||||
named: 'consultantModel',
|
||||
),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await repository.registerConsultant(consultant: consultantProfile, update: true);
|
||||
await repository.registerConsultant(
|
||||
consultant: consultantProfile,
|
||||
update: true,
|
||||
);
|
||||
|
||||
verify(() => mockService.consultantUpdate(consultantModel: consultantProfile)).called(1);
|
||||
verifyNever(() => mockService.consultantRegistration(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel')));
|
||||
});
|
||||
verify(
|
||||
() =>
|
||||
mockService.consultantUpdate(consultantModel: consultantProfile),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockService.consultantRegistration(
|
||||
consultantModel: any<ConsultantProfileModel>(
|
||||
named: 'consultantModel',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('getConsultantRequestStatus returns request from service', () async {
|
||||
when(() => mockService.getConsultantRequestStatus(slug: any<String>(named: 'slug'))).thenAnswer((_) async => consultationRequest);
|
||||
when(
|
||||
() => mockService.getConsultantRequestStatus(
|
||||
slug: any<String>(named: 'slug'),
|
||||
),
|
||||
).thenAnswer((_) async => consultationRequest);
|
||||
|
||||
final result = await repository.getConsultantRequestStatus('user-slug');
|
||||
|
||||
expect(result, equals(consultationRequest));
|
||||
verify(() => mockService.getConsultantRequestStatus(slug: 'user-slug')).called(1);
|
||||
verify(
|
||||
() => mockService.getConsultantRequestStatus(slug: 'user-slug'),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('getMyRequestStatus returns request from service', () async {
|
||||
when(() => mockService.getMyRequestStatus(slug: any<String>(named: 'slug'))).thenAnswer((_) async => consultationRequest);
|
||||
when(
|
||||
() => mockService.getMyRequestStatus(slug: any<String>(named: 'slug')),
|
||||
).thenAnswer((_) async => consultationRequest);
|
||||
|
||||
final result = await repository.getMyRequestStatus('my-slug');
|
||||
|
||||
@@ -80,7 +138,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('getConsultantCertPhotos returns photos from service', () async {
|
||||
when(() => mockService.getConsultantCertPhotos()).thenAnswer((_) async => certPhotos);
|
||||
when(
|
||||
() => mockService.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => certPhotos);
|
||||
|
||||
final result = await repository.getConsultantCertPhotos();
|
||||
|
||||
@@ -88,42 +148,92 @@ void main() {
|
||||
verify(() => mockService.getConsultantCertPhotos()).called(1);
|
||||
});
|
||||
|
||||
test('storeConsultantCertPhotos returns empty list when no files provided', () async {
|
||||
final result = await repository.storeConsultantCertPhotos(files: []);
|
||||
test('getSbpBanks returns banks from service', () async {
|
||||
final banks = FixtureReader.readAsList('consultant/sbp_banks.json')
|
||||
.map((item) => SbpBankModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
when(() => mockService.getSbpBanks()).thenAnswer((_) async => banks);
|
||||
|
||||
expect(result, isEmpty);
|
||||
verifyNever(() => mockService.storeConsultantCertPhotos(photos: any<List<MultipartFile>>(named: 'photos')));
|
||||
final result = await repository.getSbpBanks();
|
||||
|
||||
expect(result, banks);
|
||||
verify(() => mockService.getSbpBanks()).called(1);
|
||||
});
|
||||
|
||||
test('storeConsultantCertPhotos uploads prepared files and returns response', () async {
|
||||
final mockFile = MockFile();
|
||||
when(() => mockFile.readAsBytes()).thenAnswer((_) async => Uint8List.fromList([1, 2, 3]));
|
||||
when(() => mockFile.path).thenReturn('/tmp/certificate.png');
|
||||
when(() => mockService.storeConsultantCertPhotos(photos: any<List<MultipartFile>>(named: 'photos'))).thenAnswer((_) async => certPhotos);
|
||||
test(
|
||||
'storeConsultantCertPhotos returns empty list when no files provided',
|
||||
() async {
|
||||
final result = await repository.storeConsultantCertPhotos(files: []);
|
||||
|
||||
final result = await repository.storeConsultantCertPhotos(files: [mockFile]);
|
||||
expect(result, isEmpty);
|
||||
verifyNever(
|
||||
() => mockService.storeConsultantCertPhotos(
|
||||
photos: any<List<MultipartFile>>(named: 'photos'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(result, certPhotos);
|
||||
final capturedArgument = verify(() => mockService.storeConsultantCertPhotos(photos: captureAny<List<MultipartFile>>(named: 'photos'))).captured.single as List<MultipartFile>;
|
||||
expect(capturedArgument, hasLength(1));
|
||||
expect(capturedArgument.first.filename, 'certificate.png');
|
||||
});
|
||||
test(
|
||||
'storeConsultantCertPhotos uploads prepared files and returns response',
|
||||
() async {
|
||||
final mockFile = MockFile();
|
||||
when(
|
||||
() => mockFile.readAsBytes(),
|
||||
).thenAnswer((_) async => Uint8List.fromList([1, 2, 3]));
|
||||
when(() => mockFile.path).thenReturn('/tmp/certificate.png');
|
||||
when(
|
||||
() => mockService.storeConsultantCertPhotos(
|
||||
photos: any<List<MultipartFile>>(named: 'photos'),
|
||||
),
|
||||
).thenAnswer((_) async => certPhotos);
|
||||
|
||||
final result = await repository.storeConsultantCertPhotos(
|
||||
files: [mockFile],
|
||||
);
|
||||
|
||||
expect(result, certPhotos);
|
||||
final capturedArgument =
|
||||
verify(
|
||||
() => mockService.storeConsultantCertPhotos(
|
||||
photos: captureAny<List<MultipartFile>>(named: 'photos'),
|
||||
),
|
||||
).captured.single
|
||||
as List<MultipartFile>;
|
||||
expect(capturedArgument, hasLength(1));
|
||||
expect(capturedArgument.first.filename, 'certificate.png');
|
||||
},
|
||||
);
|
||||
|
||||
test('hideConsultantCertPhoto sends payload to service', () async {
|
||||
when(() => mockService.hideConsultantCertPhoto(payload: any<Map<String, dynamic>>(named: 'payload'))).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mockService.hideConsultantCertPhoto(
|
||||
payload: any<Map<String, dynamic>>(named: 'payload'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await repository.hideConsultantCertPhoto(42);
|
||||
|
||||
verify(() => mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42})).called(1);
|
||||
verify(
|
||||
() =>
|
||||
mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42}),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('unHideConsultantCertPhoto sends payload to service', () async {
|
||||
when(() => mockService.unHideConsultantCertPhoto(payload: any<Map<String, dynamic>>(named: 'payload'))).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mockService.unHideConsultantCertPhoto(
|
||||
payload: any<Map<String, dynamic>>(named: 'payload'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await repository.unHideConsultantCertPhoto(7);
|
||||
|
||||
verify(() => mockService.unHideConsultantCertPhoto(payload: {'cert_photo_id': 7})).called(1);
|
||||
verify(
|
||||
() => mockService.unHideConsultantCertPhoto(
|
||||
payload: {'cert_photo_id': 7},
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ void main() {
|
||||
|
||||
test('getMyRequestStatus - возвращает статус запроса', () async {
|
||||
const slug = 'test-consultant';
|
||||
final json = FixtureReader.readAsMap('consultant/consultation_request.json');
|
||||
final json = FixtureReader.readAsMap(
|
||||
'consultant/consultation_request.json',
|
||||
);
|
||||
final expectedRequest = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
when(mockConsultantService.getMyRequestStatus(slug: slug))
|
||||
.thenAnswer((_) async => expectedRequest);
|
||||
when(
|
||||
mockConsultantService.getMyRequestStatus(slug: slug),
|
||||
).thenAnswer((_) async => expectedRequest);
|
||||
|
||||
final result = await mockConsultantService.getMyRequestStatus(slug: slug);
|
||||
|
||||
@@ -33,25 +36,36 @@ void main() {
|
||||
verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1);
|
||||
});
|
||||
|
||||
test('getConsultantRequestStatus - возвращает статус запроса консультанта', () async {
|
||||
const slug = 'test-consultant';
|
||||
final json = FixtureReader.readAsMap('consultant/consultation_request.json');
|
||||
final expectedRequest = ConsultationRequestModel.fromJson(json);
|
||||
test(
|
||||
'getConsultantRequestStatus - возвращает статус запроса консультанта',
|
||||
() async {
|
||||
const slug = 'test-consultant';
|
||||
final json = FixtureReader.readAsMap(
|
||||
'consultant/consultation_request.json',
|
||||
);
|
||||
final expectedRequest = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
when(mockConsultantService.getConsultantRequestStatus(slug: slug))
|
||||
.thenAnswer((_) async => expectedRequest);
|
||||
when(
|
||||
mockConsultantService.getConsultantRequestStatus(slug: slug),
|
||||
).thenAnswer((_) async => expectedRequest);
|
||||
|
||||
final result = await mockConsultantService.getConsultantRequestStatus(slug: slug);
|
||||
final result = await mockConsultantService.getConsultantRequestStatus(
|
||||
slug: slug,
|
||||
);
|
||||
|
||||
expect(result, isA<ConsultationRequestModel>());
|
||||
verify(mockConsultantService.getConsultantRequestStatus(slug: slug)).called(1);
|
||||
});
|
||||
expect(result, isA<ConsultationRequestModel>());
|
||||
verify(
|
||||
mockConsultantService.getConsultantRequestStatus(slug: slug),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('getConsultantCertPhotos - возвращает фото сертификатов', () async {
|
||||
final expectedPhotos = <ConsultantCertPhotoModel>[];
|
||||
|
||||
when(mockConsultantService.getConsultantCertPhotos())
|
||||
.thenAnswer((_) async => expectedPhotos);
|
||||
when(
|
||||
mockConsultantService.getConsultantCertPhotos(),
|
||||
).thenAnswer((_) async => expectedPhotos);
|
||||
|
||||
final result = await mockConsultantService.getConsultantCertPhotos();
|
||||
|
||||
@@ -60,16 +74,22 @@ void main() {
|
||||
});
|
||||
|
||||
test('consultantRegistration - регистрирует консультанта', () async {
|
||||
final profileJson = FixtureReader.readAsMap('consultant/consultant_profile_request.json');
|
||||
final profileJson = FixtureReader.readAsMap(
|
||||
'consultant/consultant_profile_request.json',
|
||||
);
|
||||
final profile = ConsultantProfileModel.fromJson(profileJson);
|
||||
|
||||
when(mockConsultantService.consultantRegistration(consultantModel: profile))
|
||||
.thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockConsultantService.consultantRegistration(consultantModel: profile),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await mockConsultantService.consultantRegistration(consultantModel: profile);
|
||||
await mockConsultantService.consultantRegistration(
|
||||
consultantModel: profile,
|
||||
);
|
||||
|
||||
verify(mockConsultantService.consultantRegistration(consultantModel: profile)).called(1);
|
||||
verify(
|
||||
mockConsultantService.consultantRegistration(consultantModel: profile),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('parseLaravelValidationErrors', () {
|
||||
DioException dio422(Map<String, dynamic> data) {
|
||||
return DioException(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
statusCode: 422,
|
||||
data: data,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
}
|
||||
|
||||
test('maps unique and format errors to Russian UI texts', () {
|
||||
final parsed = parseLaravelValidationErrors(
|
||||
dio422({
|
||||
'message': 'The given data was invalid.',
|
||||
'errors': {
|
||||
'inn': ['The inn format is invalid.'],
|
||||
'passport_number': ['The passport number has already been taken.'],
|
||||
'snils': ['The snils has already been taken.'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.fieldErrors['inn'], 'Некорректный ИНН');
|
||||
expect(parsed.fieldErrors['passport_number'], 'Такой паспорт существует');
|
||||
expect(parsed.fieldErrors['snils'], 'Такой СНИЛС существует');
|
||||
expect(parsed.summary, isNot(contains('The given data was invalid.')));
|
||||
expect(parsed.summary, isNot(contains('already been taken')));
|
||||
expect(parsed.summary, contains('Некорректный ИНН'));
|
||||
expect(parsed.summary, contains('Такой паспорт существует'));
|
||||
expect(parsed.hasFieldErrors, isTrue);
|
||||
});
|
||||
|
||||
test('maps required and agreement errors', () {
|
||||
final parsed = parseLaravelValidationErrors(
|
||||
dio422({
|
||||
'errors': {
|
||||
'last_name': ['The last name field is required.'],
|
||||
'agreement_accepted': ['The agreement accepted must be accepted.'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.fieldErrors['last_name'], 'Укажите фамилию');
|
||||
expect(parsed.fieldErrors['agreement_accepted'], 'Примите условия оферты');
|
||||
});
|
||||
|
||||
test('falls back to generic message for non-422 DioException', () {
|
||||
final error = DioException(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
message: 'Network failed',
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
|
||||
final parsed = parseLaravelValidationErrors(error);
|
||||
|
||||
expect(parsed.summary, 'Не удалось отправить данные. Попробуйте ещё раз');
|
||||
expect(parsed.fieldErrors, isEmpty);
|
||||
});
|
||||
|
||||
test('uses generic text for non-Dio errors', () {
|
||||
final parsed = parseLaravelValidationErrors(Exception('boom'));
|
||||
|
||||
expect(parsed.summary, 'Не удалось отправить данные. Попробуйте ещё раз');
|
||||
expect(parsed.fieldErrors, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('mapConsultantValidationMessage', () {
|
||||
test('unique inn and snils', () {
|
||||
expect(
|
||||
mapConsultantValidationMessage(
|
||||
fieldKey: 'inn',
|
||||
rawMessage: 'The inn has already been taken.',
|
||||
),
|
||||
'Такой ИНН существует',
|
||||
);
|
||||
expect(
|
||||
mapConsultantValidationMessage(
|
||||
fieldKey: 'snils',
|
||||
rawMessage: 'The snils has already been taken.',
|
||||
),
|
||||
'Такой СНИЛС существует',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('FeedBlogPostModel', () {
|
||||
test('creates FeedBlogPostModel with all parameters', () {
|
||||
@@ -104,65 +102,6 @@ void main() {
|
||||
expect(model.totalLikes, 10);
|
||||
});
|
||||
|
||||
test('parses v2 show JSON with gallery images and video_url_embed', () {
|
||||
final model = FeedBlogPostModel.fromJson({
|
||||
'id': 1,
|
||||
'state': 'active',
|
||||
'image': 'media/1/cover.jpg',
|
||||
'images': [
|
||||
{'id': 10, 'url': 'media/1/a.jpg', 'position': 1, 'orientation': 'horizontal'},
|
||||
{'id': 11, 'url': 'media/1/b.jpg', 'position': 2},
|
||||
],
|
||||
'description': 'Описание',
|
||||
'author': {
|
||||
'author_slug': 'author-slug',
|
||||
'author_name': 'Автор',
|
||||
'author_profile_icon': 'icon.jpg',
|
||||
'author_is_me': false,
|
||||
},
|
||||
'video_url': 'https://rutube.ru/video/abc/',
|
||||
'video_url_embed': 'https://rutube.ru/play/embed/abc/',
|
||||
'video_url_thumb': 'media/1/thumb.jpg',
|
||||
'created_at': '2024-01-15T12:00:00Z',
|
||||
'updated_at': '2024-01-15T12:00:00Z',
|
||||
'title': 'Заголовок',
|
||||
'total_likes': 10,
|
||||
'total_views': 100,
|
||||
'total_comments': 5,
|
||||
'was_liked_by_me': false,
|
||||
});
|
||||
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.images.first.id, 10);
|
||||
expect(model.images.first.url, 'media/1/a.jpg');
|
||||
expect(model.images.first.orientation, 'horizontal');
|
||||
expect(model.images[1].orientation, 'horizontal');
|
||||
expect(model.images[1].position, 2);
|
||||
expect(model.videoEmbedUrl, 'https://rutube.ru/play/embed/abc/');
|
||||
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
|
||||
});
|
||||
|
||||
test('parses fixture feedBlogPostModel with gallery and embed', () {
|
||||
final model = FeedFixtures.feedBlogPostModel();
|
||||
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
|
||||
});
|
||||
|
||||
test('parses profile search_by_author fixture with gallery and video', () {
|
||||
final posts = FeedFixtures.blogPostsByAuthor();
|
||||
|
||||
expect(posts, hasLength(2));
|
||||
expect(posts.first.images, hasLength(2));
|
||||
expect(posts.first.images.first.orientation, 'horizontal');
|
||||
expect(posts.first.images[1].orientation, 'vertical');
|
||||
expect(posts.first.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(posts.first.videoUrlThumb, 'media/1/thumb.jpg');
|
||||
expect(posts[1].images, hasLength(2));
|
||||
expect(posts[1].videoEmbedUrl, isNull);
|
||||
});
|
||||
|
||||
test('creates FeedBlogPostModel with different states', () {
|
||||
const author = Author(
|
||||
authorSlug: 'author',
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('PostFeedMapper', () {
|
||||
setUpAll(() async {
|
||||
await sl.reset();
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
|
||||
test('maps fixture item post_id to FeedBlogPostModel.id', () {
|
||||
final item = FeedFixtures.postFeedItem();
|
||||
final model = PostFeedMapper.fromPostFeedItem(item, currentUserId: 10);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.author.authorIsMe, isTrue);
|
||||
expect(model.author.authorSlug, 'anna-ivanova');
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.videoUrl, isEmpty);
|
||||
expect(model.videoEmbedUrl, isNull);
|
||||
expect(model.title, 'Завтрак с овсянкой');
|
||||
expect(model.image, 'http://localhost/storage/posts/oatmeal.jpg');
|
||||
expect(model.author.isEntitySubscribedByMe, isFalse);
|
||||
});
|
||||
|
||||
test('maps fixture embed video post and likes', () {
|
||||
final item = FeedFixtures.postFeedItem(index: 1);
|
||||
final model = PostFeedMapper.fromPostFeedItem(item);
|
||||
|
||||
expect(model.id, 2);
|
||||
expect(model.videoUrl, isEmpty);
|
||||
expect(model.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(model.wasLikedByMe, isTrue);
|
||||
expect(model.totalLikes, 42);
|
||||
expect(model.author.authorIsMe, isFalse);
|
||||
expect(model.author.isEntitySubscribedByMe, isTrue);
|
||||
});
|
||||
|
||||
test('maps all fixture posts', () {
|
||||
final posts = FeedFixtures.mappedFeedPosts(currentUserId: 11);
|
||||
|
||||
expect(posts, hasLength(3));
|
||||
expect(posts[1].author.authorIsMe, isTrue);
|
||||
expect(posts[1].author.isEntitySubscribedByMe, isTrue);
|
||||
expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(posts[2].author.isEntitySubscribedByMe, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('PostFeedPaginationModel fixtures', () {
|
||||
test('parses first page with gallery, video and next_cursor', () {
|
||||
final model = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
expect(model.data, hasLength(3));
|
||||
expect(model.meta.perPage, 20);
|
||||
expect(model.meta.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
expect(model.meta.prevCursor, isNull);
|
||||
expect(model.links.next, contains('cursor=eyJpZCI6MTAzfQ'));
|
||||
|
||||
final withGallery = model.data[0];
|
||||
expect(withGallery.postId, 1);
|
||||
expect(withGallery.images, hasLength(2));
|
||||
expect(withGallery.images.first.orientation, 'horizontal');
|
||||
expect(withGallery.videoEmbedUrl, isNull);
|
||||
expect(withGallery.isEntitySubscribedByMe, isFalse);
|
||||
expect(withGallery.postAuthorDetails?.slug, 'anna-ivanova');
|
||||
expect(withGallery.postAuthorDetails?.isConsultant, isTrue);
|
||||
|
||||
final withYoutubeEmbed = model.data[1];
|
||||
expect(withYoutubeEmbed.postId, 2);
|
||||
expect(withYoutubeEmbed.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(withYoutubeEmbed.postIsLikedByUser, isTrue);
|
||||
expect(withYoutubeEmbed.isEntitySubscribedByMe, isTrue);
|
||||
expect(withYoutubeEmbed.images, isEmpty);
|
||||
|
||||
final withRutubeEmbed = model.data[2];
|
||||
expect(withRutubeEmbed.postId, 3);
|
||||
expect(withRutubeEmbed.postImageUrl, isNull);
|
||||
expect(withRutubeEmbed.images, hasLength(1));
|
||||
expect(withRutubeEmbed.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(withRutubeEmbed.isEntitySubscribedByMe, isNull); });
|
||||
|
||||
test('parses next page without next_cursor', () {
|
||||
final model = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
expect(model.data, hasLength(1));
|
||||
expect(model.data.first.postId, 4);
|
||||
expect(model.meta.nextCursor, isNull);
|
||||
expect(model.meta.prevCursor, 'eyJpZCI6MTAzfQ');
|
||||
expect(model.links.next, isNull);
|
||||
});
|
||||
|
||||
test('parses empty feed', () {
|
||||
final model = FeedFixtures.postFeedEmptyPaginationModel();
|
||||
|
||||
expect(model.data, isEmpty);
|
||||
expect(model.meta.nextCursor, isNull);
|
||||
expect(model.links.next, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/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/repositories/feed_repository.dart';
|
||||
@@ -11,52 +13,211 @@ import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit cursor flow', () {
|
||||
late FeedCubit cubit;
|
||||
late MockFeedRepository repository;
|
||||
|
||||
final post = FeedBlogPostModel(
|
||||
id: 10,
|
||||
state: 'active',
|
||||
image: '',
|
||||
description: 'description',
|
||||
author: const Author(authorSlug: 'slug', authorName: 'name', authorProfileIcon: '', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'title',
|
||||
totalLikes: 1,
|
||||
totalViews: 1,
|
||||
totalComments: 1,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late FeedBlogPostSettingsModel feedBlogPostsFixture;
|
||||
|
||||
setUp(() {
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
|
||||
// Создаём тестовые данные
|
||||
final testPosts = [
|
||||
FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
image: 'https://example.com/image1.jpg',
|
||||
description: 'Описание поста 1',
|
||||
author: const Author(
|
||||
authorSlug: 'author-1',
|
||||
authorName: 'Автор 1',
|
||||
authorProfileIcon: 'https://example.com/photo1.jpg',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: null,
|
||||
authorPlaceInRating: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
title: 'Тестовый пост 1',
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
),
|
||||
FeedBlogPostModel(
|
||||
id: 2,
|
||||
state: 'published',
|
||||
image: 'https://example.com/image2.jpg',
|
||||
description: 'Описание поста 2',
|
||||
author: const Author(
|
||||
authorSlug: 'author-2',
|
||||
authorName: 'Автор 2',
|
||||
authorProfileIcon: 'https://example.com/photo2.jpg',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: null,
|
||||
authorPlaceInRating: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
title: 'Тестовый пост 2',
|
||||
totalLikes: 20,
|
||||
totalViews: 200,
|
||||
totalComments: 10,
|
||||
wasLikedByMe: true,
|
||||
),
|
||||
];
|
||||
|
||||
feedBlogPostsFixture = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: testPosts,
|
||||
meta: const Meta(
|
||||
currentPage: 1,
|
||||
from: null,
|
||||
lastPage: 3,
|
||||
links: [],
|
||||
path: null,
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 25,
|
||||
totalMinPrice: null,
|
||||
totalMaxPrice: null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('error state on load failure', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateError>());
|
||||
test('начальное состояние - FeedStateLoading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList updates likes in loaded state', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null));
|
||||
await cubit.loadFeedBlogPostList();
|
||||
test('loadFeedBlogPostList - успешная загрузка постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
cubit.updateFeedBlogPostList(post, true);
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loaded.feedBlogPostList.first.totalLikes, 2);
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
expect(loadedState.isLoading, false);
|
||||
expect(feedCubit!.lastPage, 3);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - загрузка с запросом', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'тест';
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - обработка ошибки', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
|
||||
.thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception'));
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - обновление лайка поста', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
// Сначала загружаем посты
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
// Устанавливаем состояние loaded вручную для теста
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
|
||||
expect(updatedPost.wasLikedByMe, true);
|
||||
expect(updatedPost.totalLikes, postToUpdate.totalLikes + 1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - снятие лайка', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList[1]; // У этого wasLikedByMe = true
|
||||
final initialLikes = postToUpdate.totalLikes;
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, false);
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
|
||||
expect(updatedPost.wasLikedByMe, false);
|
||||
expect(updatedPost.totalLikes, initialLikes - 1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - игнорирует обновление если состояние не loaded', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
// Состояние loading
|
||||
feedCubit!.emit(const FeedState.loading());
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
|
||||
|
||||
// Состояние не должно измениться
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - игнорирует обновление если пост не найден', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
// Создаём пост с несуществующим ID
|
||||
final nonExistentPost = feedBlogPostsFixture.feedBlogPostList.first.copyWith(id: 999);
|
||||
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
|
||||
|
||||
// Список не должен измениться
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +1,404 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/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/repositories/feed_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
late MockFeedRepository repository;
|
||||
late FeedCubit cubit;
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
|
||||
final post = FeedBlogPostModel(
|
||||
final testBlogPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'active',
|
||||
image: 'https://example.com/image.png',
|
||||
description: 'text',
|
||||
author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'Title',
|
||||
totalLikes: 1,
|
||||
totalViews: 2,
|
||||
totalComments: 3,
|
||||
title: 'Test Post',
|
||||
description: 'Test description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
final testBlogPost2 = FeedBlogPostModel(
|
||||
id: 2,
|
||||
title: 'Test Post 2',
|
||||
description: 'Test description 2',
|
||||
image: 'https://example.com/image2.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-2', authorName: 'Author Name 2', authorProfileIcon: 'https://example.com/icon2.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video2.mp4',
|
||||
createdAt: DateTime(2024, 1, 16),
|
||||
updatedAt: DateTime(2024, 1, 16),
|
||||
totalLikes: 20,
|
||||
totalViews: 200,
|
||||
totalComments: 10,
|
||||
wasLikedByMe: true,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList emits loaded from getFeed', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateLoaded>());
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(1));
|
||||
verify(repository.getFeed(search: null, cursor: null, currentUserId: null)).called(1);
|
||||
test('начальное состояние - loading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('loadNextPage appends posts', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null));
|
||||
test('loadFeedBlogPostList - успешно загружает список постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
await cubit.loadNextPage();
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings);
|
||||
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(2));
|
||||
verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1);
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 1);
|
||||
expect(loadedState.feedBlogPostList.first.id, 1);
|
||||
expect(loadedState.isLoading, false);
|
||||
expect(feedCubit!.lastPage, 3);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - загружает список постов с query', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'test query';
|
||||
final expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost, testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => expectedSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - обрабатывает ошибку', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final exception = Exception('Network error');
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception: Network error'));
|
||||
});
|
||||
|
||||
test('loadNextPage - загружает следующую страницу', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
final secondPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenAnswer((_) async => secondPageSettings);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
expect(feedCubit!.currentPage, 2);
|
||||
expect(loadedState.isLoading, false);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadNextPage - не загружает если уже на последней странице', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
|
||||
});
|
||||
|
||||
test('loadNextPage - не загружает если уже загружается', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.isLoading = true;
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
|
||||
});
|
||||
|
||||
test('loadNextPage - обрабатывает ошибку при загрузке следующей страницы', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final exception = Exception('Network error');
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception: Network error'));
|
||||
});
|
||||
|
||||
test('loadNextPage - сохраняет query при загрузке следующей страницы', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'test query';
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
final secondPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).thenAnswer((_) async => secondPageSettings);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).called(1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - обновляет лайк поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loadedState.feedBlogPostList.first.totalLikes, 11);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - убирает лайк поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost2, false);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.first.wasLikedByMe, false);
|
||||
expect(loadedState.feedBlogPostList.first.totalLikes, 19);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если пост не найден', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final nonExistentPost = testBlogPost.copyWith(id: 999);
|
||||
final initialState = feedCubit!.state;
|
||||
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, initialState);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если state не loaded', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если post null', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final initialState = feedCubit!.state;
|
||||
feedCubit!.updateFeedBlogPostList(null, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, initialState);
|
||||
});
|
||||
|
||||
test('likePost - лайкает пост', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.likeBlogPost(id: postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.likePost(postId);
|
||||
|
||||
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('likePost - обрабатывает ошибку без выбрасывания', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
final exception = Exception('Network error');
|
||||
|
||||
when(mockFeedRepository.likeBlogPost(id: postId)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.likePost(postId);
|
||||
|
||||
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('unlikedPost - убирает лайк с поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.unlikedBlogPost(id: postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.unlikedPost(postId);
|
||||
|
||||
verify(mockFeedRepository.unlikedBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('viewPost - отправляет просмотр поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.viewPost(postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewPost(postId);
|
||||
|
||||
verify(mockFeedRepository.viewPost(postId)).called(1);
|
||||
});
|
||||
|
||||
test('viewRecipe - отправляет просмотр рецепта', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const slug = 'recipe-1';
|
||||
|
||||
when(mockFeedRepository.viewRecipe(slug)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewRecipe(slug);
|
||||
|
||||
verify(mockFeedRepository.viewRecipe(slug)).called(1);
|
||||
});
|
||||
|
||||
test('viewDiet - отправляет просмотр диеты', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const dietId = 2;
|
||||
|
||||
when(mockFeedRepository.viewDiet(dietId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewDiet(dietId);
|
||||
|
||||
verify(mockFeedRepository.viewDiet(dietId)).called(1);
|
||||
});
|
||||
|
||||
test('viewPlan - отправляет просмотр плана', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const slug = 'plan-1';
|
||||
|
||||
when(mockFeedRepository.viewPlan(slug)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewPlan(slug);
|
||||
|
||||
verify(mockFeedRepository.viewPlan(slug)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/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_posts.dart';
|
||||
@@ -376,8 +378,12 @@ void main() {
|
||||
// Arrange
|
||||
feedCubit.emit(FeedState.loaded(feedBlogPostList: [testBlogPost], isLoading: false));
|
||||
|
||||
when(mockFeedRepository.getFeed(search: null, cursor: null, currentUserId: null))
|
||||
.thenAnswer((_) async => (posts: [testBlogPost, testBlogPost2], nextCursor: null));
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer(
|
||||
(_) async => FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost, testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
),
|
||||
);
|
||||
|
||||
final widget = BlogPosts(handleVisibility: ({required visibleFraction, required id, required type}) {});
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ void main() {
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(BlocProvider<FeedCubit>.value(value: cubit, child: wrap(BlogPostWidget(post: mockBlogPost))));
|
||||
await tester.pumpWidget(BlocProvider<FeedCubit>.value(value: cubit, child: wrap(const BlogPostWidget(index: 0))));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
@@ -13,11 +11,9 @@ import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
import 'feed_repository_test.mocks.dart';
|
||||
|
||||
@@ -27,57 +23,72 @@ void main() {
|
||||
late FeedRepository feedRepository;
|
||||
late MockFeedService mockFeedService;
|
||||
|
||||
setUp(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
setUp(() {
|
||||
mockFeedService = MockFeedService();
|
||||
feedRepository = FeedRepository(feedService: mockFeedService);
|
||||
});
|
||||
|
||||
test('getFeed - успешное получение постов', () async {
|
||||
final expectedModel = FeedFixtures.postFeedPaginationModel();
|
||||
test('getFeedBlogPosts - успешное получение постов', () async {
|
||||
const expectedModel = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], path: null, perPage: 10, to: null, total: 0, totalMinPrice: null, totalMaxPrice: null),
|
||||
);
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getBlogPostList(page: 1, query: null)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeed();
|
||||
final result = await feedRepository.getFeedBlogPosts();
|
||||
|
||||
expect(result.posts, hasLength(3));
|
||||
expect(result.posts.first.id, 1);
|
||||
expect(result.posts[1].videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(result.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
verify(mockFeedService.getFeed(search: null, cursor: null)).called(1);
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
verify(mockFeedService.getBlogPostList(page: 1, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed - с запросом и курсором', () async {
|
||||
test('getFeedBlogPosts - с запросом и страницей', () async {
|
||||
const query = 'тест';
|
||||
const cursor = 'eyJpZCI6MTAzfQ';
|
||||
final expectedModel = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
const page = 2;
|
||||
const expectedModel = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(
|
||||
currentPage: page,
|
||||
from: null,
|
||||
lastPage: 2,
|
||||
links: [],
|
||||
path: null,
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 15,
|
||||
totalMinPrice: null,
|
||||
totalMaxPrice: null,
|
||||
),
|
||||
);
|
||||
|
||||
when(mockFeedService.getFeed(search: query, cursor: cursor)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getBlogPostList(page: page, query: query)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeed(search: query, cursor: cursor);
|
||||
final result = await feedRepository.getFeedBlogPosts(query: query, page: page);
|
||||
|
||||
expect(result.posts, hasLength(1));
|
||||
expect(result.posts.first.id, 4);
|
||||
expect(result.nextCursor, isNull);
|
||||
verify(mockFeedService.getFeed(search: query, cursor: cursor)).called(1);
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: query)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed - пустая лента', () async {
|
||||
final expectedModel = FeedFixtures.postFeedEmptyPaginationModel();
|
||||
test('getFeedBlogPosts - возвращает список постов без query', () async {
|
||||
const page = 1;
|
||||
const expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(currentPage: 1, lastPage: 1, total: 0, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getBlogPostList(page: page, query: null)).thenAnswer((_) async => expectedSettings);
|
||||
|
||||
final result = await feedRepository.getFeed();
|
||||
final result = await feedRepository.getFeedBlogPosts(page: page);
|
||||
|
||||
expect(result.posts, isEmpty);
|
||||
expect(result.nextCursor, isNull);
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
expect(result.feedBlogPostList.isEmpty, true);
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('getBlogPost - успешное получение поста', () async {
|
||||
const postId = 1;
|
||||
final expectedPost = FeedFixtures.feedBlogPostModel();
|
||||
final json = FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||
final expectedPost = FeedBlogPostModel.fromJson(json);
|
||||
|
||||
when(mockFeedService.getBlogPost(postId: postId)).thenAnswer((_) async => expectedPost);
|
||||
|
||||
@@ -85,9 +96,6 @@ void main() {
|
||||
|
||||
expect(result, isA<FeedBlogPostModel>());
|
||||
expect(result.id, postId);
|
||||
expect(result.images, hasLength(2));
|
||||
expect(result.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(result.videoUrlThumb, isNotEmpty);
|
||||
verify(mockFeedService.getBlogPost(postId: postId)).called(1);
|
||||
});
|
||||
|
||||
@@ -521,10 +529,10 @@ void main() {
|
||||
verify(mockFeedService.viewContent(viewModel: viewModel)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed - пробрасывает исключение', () async {
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenThrow(Exception('Ошибка сети'));
|
||||
test('getFeedBlogPosts - пробрасывает исключение', () async {
|
||||
when(mockFeedService.getBlogPostList(page: 1, query: null)).thenThrow(Exception('Ошибка сети'));
|
||||
|
||||
expect(() => feedRepository.getFeed(), throwsException);
|
||||
expect(() => feedRepository.getFeedBlogPosts(), throwsException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import 'feed_service_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedService>()])
|
||||
@@ -56,32 +55,6 @@ void main() {
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed returns cursor response', () async {
|
||||
final expected = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockFeedService.getFeed(search: null, cursor: null);
|
||||
|
||||
expect(result.data, hasLength(3));
|
||||
expect(result.meta.perPage, 20);
|
||||
expect(result.meta.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
verify(mockFeedService.getFeed(search: null, cursor: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed returns next page without cursor', () async {
|
||||
const cursor = 'eyJpZCI6MTAzfQ';
|
||||
final expected = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: cursor)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockFeedService.getFeed(search: null, cursor: cursor);
|
||||
|
||||
expect(result.data, hasLength(1));
|
||||
expect(result.meta.nextCursor, isNull);
|
||||
verify(mockFeedService.getFeed(search: null, cursor: cursor)).called(1);
|
||||
});
|
||||
|
||||
test('getBlogPost returns post', () async {
|
||||
const id = 10;
|
||||
final expected = FeedBlogPostModel(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.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/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
@@ -9,7 +10,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../../../fixtures/feed_fixtures.dart';
|
||||
import 'profile_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
@@ -267,10 +267,20 @@ void main() {
|
||||
authorPlaceInRating: null,
|
||||
);
|
||||
|
||||
final blogPost = FeedFixtures.feedBlogPostGalleryAndVideo().copyWith(
|
||||
final blogPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
title: 'Test Post',
|
||||
description: 'Test Content',
|
||||
image: 'https://example.com/image.jpg',
|
||||
author: author,
|
||||
wasLikedByMe: false,
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 10,
|
||||
totalComments: 5,
|
||||
totalViews: 100,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
const profileDetails = ProfileDetailsModel(
|
||||
|
||||
@@ -5,20 +5,20 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
import '../../../../fixtures/feed_fixtures.dart';
|
||||
import 'profile_blog_post_widget_smoke_test.mocks.dart';
|
||||
|
||||
class _FakeHttpClient implements HttpClient {
|
||||
@@ -35,6 +35,7 @@ class _FakeHttpClient implements HttpClient {
|
||||
@override
|
||||
Future<HttpClientRequest> getUrl(Uri url) async => _FakeHttpClientRequest();
|
||||
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -50,10 +51,73 @@ class _FakeHttpClientRequest implements HttpClientRequest {
|
||||
class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientResponse {
|
||||
// 1x1 transparent PNG.
|
||||
static final List<int> _png = <int>[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
0x89,
|
||||
0x50,
|
||||
0x4E,
|
||||
0x47,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x1A,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0D,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x08,
|
||||
0x06,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1F,
|
||||
0x15,
|
||||
0xC4,
|
||||
0x89,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x49,
|
||||
0x44,
|
||||
0x41,
|
||||
0x54,
|
||||
0x78,
|
||||
0x9C,
|
||||
0x63,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x2D,
|
||||
0xB4,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4E,
|
||||
0x44,
|
||||
0xAE,
|
||||
0x42,
|
||||
0x60,
|
||||
0x82,
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -86,53 +150,81 @@ void main() {
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
|
||||
const profile = ProfileModel(
|
||||
id: 1,
|
||||
name: 'User',
|
||||
email: 'u@e.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
slug: 'user',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: '',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
testWidgets('renders blog post card', (tester) async {
|
||||
final post = FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
image: 'test.png',
|
||||
description: 'Hello',
|
||||
author: const Author(
|
||||
authorSlug: 'author',
|
||||
authorName: 'Author',
|
||||
authorProfileIcon: 'https://example.com/icon.png',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
title: 'Title',
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
const details = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: '',
|
||||
aboutTakingPils: '',
|
||||
aboutSportHobbies: '',
|
||||
favoriteFoods: '',
|
||||
unfavoriteFoods: '',
|
||||
additionalInfo: '',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
const profile = ProfileModel(
|
||||
id: 1,
|
||||
name: 'User',
|
||||
email: 'u@e.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
slug: 'user',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: '',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
const details = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: '',
|
||||
aboutTakingPils: '',
|
||||
aboutSportHobbies: '',
|
||||
favoriteFoods: '',
|
||||
unfavoriteFoods: '',
|
||||
additionalInfo: '',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
Future<void> pumpPost(WidgetTester tester, ProfileCubit cubit) async {
|
||||
await HttpOverrides.runZoned(() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -144,60 +236,15 @@ void main() {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(
|
||||
body: MediaQuery(
|
||||
data: const MediaQueryData(size: Size(800, 1600)),
|
||||
child: SingleChildScrollView(
|
||||
child: ProfileBlogPostWidget(index: 0, profileCubit: cubit),
|
||||
),
|
||||
),
|
||||
),
|
||||
home: Scaffold(body: ProfileBlogPostWidget(index: 0, profileCubit: cubit)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}, createHttpClient: (_) => _FakeHttpClient());
|
||||
}
|
||||
}, createHttpClient: (context) => _FakeHttpClient());
|
||||
|
||||
testWidgets('renders gallery and video media content', (tester) async {
|
||||
final post = FeedFixtures.feedBlogPostGalleryAndVideo();
|
||||
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await pumpPost(tester, cubit);
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.textContaining('Галерея и видео'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('renders gallery-only media content', (tester) async {
|
||||
final post = FeedFixtures.feedBlogPostWithGallery();
|
||||
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await pumpPost(tester, cubit);
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Галерея'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders video-only media content', (tester) async {
|
||||
final post = FeedFixtures.feedBlogPostVideoOnly();
|
||||
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await pumpPost(tester, cubit);
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Видео'), findsOneWidget);
|
||||
expect(find.text('Регистрация'), findsNothing);
|
||||
expect(find.text('Title'), findsOneWidget);
|
||||
expect(find.text('Author'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/services/profile_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'profile_service_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<ProfileService>()])
|
||||
@@ -74,17 +72,9 @@ void main() {
|
||||
expect(await mockProfileService.getProfileDetailsBySlug(slug: 'slug'), isA<ProfileDetailsModel>());
|
||||
});
|
||||
|
||||
test('getBlogPostByAuthorSlug returns posts with gallery and video fields', () async {
|
||||
final posts = FeedFixtures.blogPostsByAuthor();
|
||||
when(mockProfileService.getBlogPostByAuthorSlug(slug: 'slug')).thenAnswer((_) async => posts);
|
||||
|
||||
final result = await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug');
|
||||
|
||||
expect(result, isA<List<FeedBlogPostModel>>());
|
||||
expect(result, hasLength(2));
|
||||
expect(result.first.images, isNotEmpty);
|
||||
expect(result.first.videoEmbedUrl, isNotNull);
|
||||
expect(result.first.videoUrlThumb, isNotNull);
|
||||
test('getBlogPostByAuthorSlug', () async {
|
||||
when(mockProfileService.getBlogPostByAuthorSlug(slug: '')).thenAnswer((_) async => []);
|
||||
expect(await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'), isA<List<FeedBlogPostModel>>());
|
||||
});
|
||||
|
||||
test('getWeight', () async {
|
||||
|
||||
@@ -20,6 +20,26 @@
|
||||
"title": "Психология питания",
|
||||
"date": "2023-06-05"
|
||||
}
|
||||
]
|
||||
],
|
||||
"legal_type": "individualPerson",
|
||||
"last_name": "Иванов",
|
||||
"first_name": "Иван",
|
||||
"middle_name": "Иванович",
|
||||
"inn": "123456789012",
|
||||
"snils": "123-456-789 00",
|
||||
"passport_series": "1234",
|
||||
"passport_number": "567890",
|
||||
"passport_issue_date": "2020-01-15",
|
||||
"passport_issuer": "ОВД Пресненского района г. Москвы",
|
||||
"passport_issuer_code": "772-053",
|
||||
"birth_date": "1990-05-20",
|
||||
"birth_place": "г. Москва",
|
||||
"registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"citizenship": "RU",
|
||||
"tax_resident_us": false,
|
||||
"foreign_tax_resident": false,
|
||||
"sbp_phone": "+79991234567",
|
||||
"agreement_accepted": true,
|
||||
"frdo_agreement_accepted": true
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,31 @@
|
||||
"personal_site_link": "https://consultant.eatvio.app",
|
||||
"request_state": "approved"
|
||||
},
|
||||
"legal_entity_resource": {
|
||||
"id": 1,
|
||||
"type": "individualPerson",
|
||||
"last_name": "Иванов",
|
||||
"first_name": "Иван",
|
||||
"middle_name": "Иванович",
|
||||
"inn": "123456789012",
|
||||
"snils": "123-456-789 00",
|
||||
"state": "created",
|
||||
"alfa_scoring_status": "scoring_failed",
|
||||
"alfa_scoring_request_id": "abc-123",
|
||||
"passport_series": "1234",
|
||||
"passport_number": "567890",
|
||||
"passport_issue_date": "2020-01-15",
|
||||
"passport_issuer": "ОВД Пресненского района г. Москвы",
|
||||
"passport_issuer_code": "772-053",
|
||||
"birth_date": "1990-05-20",
|
||||
"birth_place": "г. Москва",
|
||||
"registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
|
||||
"citizenship": "RU",
|
||||
"tax_resident_us": false,
|
||||
"foreign_tax_resident": false,
|
||||
"sbp_phone": "+79991234567"
|
||||
},
|
||||
"cert_photos": [
|
||||
{
|
||||
"url": "https://cdn.eatvio.app/certificates/visible.jpg"
|
||||
@@ -40,4 +65,3 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
test/fixtures/consultant/sbp_banks.json
vendored
Normal file
16
test/fixtures/consultant/sbp_banks.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"bank_member_id": "100000000111",
|
||||
"bank_name": "Сбербанк",
|
||||
"short_name": "Сбер",
|
||||
"logo_url": "https://example.com/sber.png",
|
||||
"bic": "044525225"
|
||||
},
|
||||
{
|
||||
"bank_member_id": "100000000004",
|
||||
"bank_name": "Т-Банк",
|
||||
"short_name": "ТБанк",
|
||||
"logo_url": "https://example.com/tbank.png",
|
||||
"bic": "044525974"
|
||||
}
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"id": 4,
|
||||
"state": "active",
|
||||
"image": "media/4/a.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 40,
|
||||
"url": "media/4/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"url": "media/4/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"description": "Галерея и видео",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
"author_name": "Автор",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": false,
|
||||
"is_entity_subscribed_by_me": true,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_thumb": "media/4/thumb.jpg",
|
||||
"created_at": "2024-01-18T12:00:00Z",
|
||||
"updated_at": "2024-01-18T12:00:00Z",
|
||||
"title": "Галерея и видео",
|
||||
"total_likes": 12,
|
||||
"total_views": 200,
|
||||
"total_comments": 4,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"id": 2,
|
||||
"state": "active",
|
||||
"image": "media/2/a.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 20,
|
||||
"url": "media/2/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"url": "media/2/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"url": "media/2/c.jpg",
|
||||
"position": 3,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"description": "Пост только с галереей",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
"author_name": "Автор",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "",
|
||||
"video_url_embed": null,
|
||||
"video_url_thumb": null,
|
||||
"created_at": "2024-01-16T12:00:00Z",
|
||||
"updated_at": "2024-01-16T12:00:00Z",
|
||||
"title": "Галерея",
|
||||
"total_likes": 3,
|
||||
"total_views": 40,
|
||||
"total_comments": 1,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
26
test/fixtures/feed/feed_blog_post_response.json
vendored
26
test/fixtures/feed/feed_blog_post_response.json
vendored
@@ -1,21 +1,7 @@
|
||||
{
|
||||
"id": 1,
|
||||
"state": "active",
|
||||
"image": "media/1/cover.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 10,
|
||||
"url": "media/1/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"url": "media/1/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"state": "published",
|
||||
"image": "https://example.com/image.jpg",
|
||||
"description": "Описание поста",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
@@ -25,15 +11,13 @@
|
||||
"is_entity_subscribed_by_me": null,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_thumb": "media/1/thumb.jpg",
|
||||
"video_url": "",
|
||||
"created_at": "2024-01-15T12:00:00Z",
|
||||
"updated_at": "2024-01-15T12:00:00Z",
|
||||
"title": "Заголовок",
|
||||
"total_likes": 10,
|
||||
"total_views": 100,
|
||||
"total_comments": 5,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
"was_liked_by_me": false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"id": 3,
|
||||
"state": "active",
|
||||
"image": "images/element-image-placeholder.svg",
|
||||
"images": [],
|
||||
"description": "Пост только с видео",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
"author_name": "Автор",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"video_url_embed": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url_thumb": "media/3/thumb.jpg",
|
||||
"created_at": "2024-01-17T12:00:00Z",
|
||||
"updated_at": "2024-01-17T12:00:00Z",
|
||||
"title": "Видео",
|
||||
"total_likes": 8,
|
||||
"total_views": 90,
|
||||
"total_comments": 2,
|
||||
"was_liked_by_me": true,
|
||||
"is_favorite": false
|
||||
}
|
||||
15
test/fixtures/feed/post_feed_empty_response.json
vendored
15
test/fixtures/feed/post_feed_empty_response.json
vendored
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"data": [],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": null,
|
||||
"next": null
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": null,
|
||||
"prev_cursor": null
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 104,
|
||||
"post_id": 4,
|
||||
"post_author_id": 13,
|
||||
"post_author_name": "Олег Кузнецов",
|
||||
"post_author_image_url": "/storage/avatars/oleg.png",
|
||||
"post_author_profile_url": "/ru/profiles/oleg-kuznetsov",
|
||||
"post_created_at": "2024-06-04T11:00:00.000000Z",
|
||||
"post_title": "Ужин за 20 минут",
|
||||
"post_short_description": "Быстрый ужин после работы",
|
||||
"post_text_md": "Курица с овощами на сковороде.",
|
||||
"post_image_url": "/storage/posts/dinner.jpg",
|
||||
"images": [],
|
||||
"video_embed_url": null,
|
||||
"post_views_count": 88,
|
||||
"post_likes_count": 7,
|
||||
"post_comments_count": 2,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"post_url": "/ru/blog_post/4",
|
||||
"video_url_thumb": null,
|
||||
"post_state": "active",
|
||||
"post_author_detailz": {
|
||||
"slug": "oleg-kuznetsov",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-04T11:05:00.000000Z",
|
||||
"updated_at": "2024-06-04T11:05:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ",
|
||||
"next": null
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": null,
|
||||
"prev_cursor": "eyJpZCI6MTAzfQ"
|
||||
}
|
||||
}
|
||||
115
test/fixtures/feed/post_feed_response.json
vendored
115
test/fixtures/feed/post_feed_response.json
vendored
@@ -1,115 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"post_id": 1,
|
||||
"post_author_id": 10,
|
||||
"post_author_name": "Анна Иванова",
|
||||
"post_author_image_url": "/storage/avatars/anna.png",
|
||||
"post_author_profile_url": "/ru/profiles/anna-ivanova",
|
||||
"post_created_at": "2024-06-01T10:00:00.000000Z",
|
||||
"post_title": "Завтрак с овсянкой",
|
||||
"post_short_description": "Простой и полезный завтрак",
|
||||
"post_text_md": "Сегодня приготовила **овсянку** с ягодами.",
|
||||
"post_image_url": "/storage/posts/oatmeal.jpg",
|
||||
"images": [
|
||||
{
|
||||
"url": "/storage/posts/oatmeal-1.jpg",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"url": "/storage/posts/oatmeal-2.jpg",
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"video_embed_url": null,
|
||||
"post_views_count": 120,
|
||||
"post_likes_count": 15,
|
||||
"post_comments_count": 4,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"post_url": "/ru/blog_post/1",
|
||||
"post_state": "active",
|
||||
"video_url_thumb": null,
|
||||
"post_author_detailz": {
|
||||
"slug": "anna-ivanova",
|
||||
"is_consultant": true
|
||||
},
|
||||
"created_at": "2024-06-01T10:05:00.000000Z",
|
||||
"updated_at": "2024-06-01T10:05:00.000000Z"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"post_id": 2,
|
||||
"post_author_id": 11,
|
||||
"post_author_name": "Игорь Петров",
|
||||
"post_author_image_url": "/storage/avatars/igor.png",
|
||||
"post_author_profile_url": "/ru/profiles/igor-petrov",
|
||||
"post_created_at": "2024-06-02T08:30:00.000000Z",
|
||||
"post_title": "Тренировка дома",
|
||||
"post_short_description": "Короткая тренировка без оборудования",
|
||||
"post_text_md": "15 минут кардио и растяжка.",
|
||||
"post_image_url": "/storage/posts/workout.jpg",
|
||||
"images": [],
|
||||
"video_embed_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"post_views_count": 340,
|
||||
"post_likes_count": 42,
|
||||
"post_comments_count": 9,
|
||||
"post_is_liked_by_user": true,
|
||||
"is_entity_subscribed_by_me": true,
|
||||
"post_url": "/ru/blog_post/2",
|
||||
"post_state": "active",
|
||||
"post_author_detailz": {
|
||||
"slug": "igor-petrov",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-02T08:35:00.000000Z",
|
||||
"updated_at": "2024-06-02T09:00:00.000000Z"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"post_id": 3,
|
||||
"post_author_id": 12,
|
||||
"post_author_name": "Мария Смирнова",
|
||||
"post_author_image_url": null,
|
||||
"post_author_profile_url": "/ru/profiles/maria-smirnova",
|
||||
"post_created_at": "2024-06-03T14:15:00.000000Z",
|
||||
"post_title": "Рецепт смузи",
|
||||
"post_short_description": null,
|
||||
"post_text_md": null,
|
||||
"post_image_url": null,
|
||||
"images": [
|
||||
{
|
||||
"url": "/storage/posts/smoothie.jpg",
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"video_embed_url": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"post_views_count": 50,
|
||||
"post_likes_count": 3,
|
||||
"post_comments_count": 1,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": null,
|
||||
"post_url": "/ru/blog_post/3",
|
||||
"post_state": "active",
|
||||
"post_author_detailz": {
|
||||
"slug": "maria-smirnova",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-03T14:20:00.000000Z",
|
||||
"updated_at": "2024-06-03T14:20:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": null,
|
||||
"next": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ"
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": "eyJpZCI6MTAzfQ",
|
||||
"prev_cursor": null
|
||||
}
|
||||
}
|
||||
81
test/fixtures/feed_fixtures.dart
vendored
81
test/fixtures/feed_fixtures.dart
vendored
@@ -1,81 +0,0 @@
|
||||
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/models/post_feed_pagination_model.dart';
|
||||
|
||||
import 'fixture_reader.dart';
|
||||
|
||||
class FeedFixtures {
|
||||
FeedFixtures._();
|
||||
|
||||
static Map<String, dynamic> postFeedResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> postFeedNextPageResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_next_page_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> postFeedEmptyResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_empty_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostGalleryResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostVideoOnlyResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostGalleryAndVideoResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json');
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedPaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedNextPagePaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedNextPageResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedEmptyPaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedEmptyResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedItemModel postFeedItem({int index = 0}) {
|
||||
return postFeedPaginationModel().data[index];
|
||||
}
|
||||
|
||||
static List<FeedBlogPostModel> mappedFeedPosts({int? currentUserId}) {
|
||||
return postFeedPaginationModel().data
|
||||
.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostModel() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostResponseJson());
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostWithGallery() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostGalleryResponseJson());
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostVideoOnly() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostVideoOnlyResponseJson());
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostGalleryAndVideo() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson());
|
||||
}
|
||||
|
||||
static List<FeedBlogPostModel> blogPostsByAuthor() {
|
||||
return FixtureReader.readAsList('profile/blog_posts_by_author_response.json')
|
||||
.map((item) => FeedBlogPostModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"state": "active",
|
||||
"image": "media/1/cover.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 10,
|
||||
"url": "media/1/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"url": "media/1/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"description": "Описание поста профиля",
|
||||
"author": {
|
||||
"author_slug": "test-user",
|
||||
"author_name": "Test User",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": true,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_thumb": "media/1/thumb.jpg",
|
||||
"created_at": "2024-01-15T12:00:00Z",
|
||||
"updated_at": "2024-01-15T12:00:00Z",
|
||||
"title": "Пост профиля",
|
||||
"total_likes": 10,
|
||||
"total_views": 100,
|
||||
"total_comments": 5,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"state": "active",
|
||||
"image": "media/2/a.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 20,
|
||||
"url": "media/2/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"url": "media/2/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"description": "Галерея без видео",
|
||||
"author": {
|
||||
"author_slug": "test-user",
|
||||
"author_name": "Test User",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": true,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "",
|
||||
"video_url_embed": null,
|
||||
"video_url_thumb": null,
|
||||
"created_at": "2024-01-16T12:00:00Z",
|
||||
"updated_at": "2024-01-16T12:00:00Z",
|
||||
"title": "Галерея",
|
||||
"total_likes": 3,
|
||||
"total_views": 40,
|
||||
"total_comments": 1,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user