Compare commits

...

41 Commits

Author SHA1 Message Date
misha
8494e61eec version changed 2026-08-13 12:29:18 +03:00
misha
5c2458ecad feat(consultant_refistration): added explicit validation for scoring data 2026-08-13 12:28:34 +03:00
b63bab8bf5 Merge pull request 'feat(post_creation_with_images)' (#1026) from post_creation_with_images into main
Reviewed-on: eatvio/eatvio_app#1026
2026-08-05 14:07:55 +00:00
7790014726 Merge pull request 'Version changed' (#1029) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1029
2026-08-05 13:33:20 +00:00
misha
0d588bcd57 Version changed 2026-08-05 16:32:14 +03:00
b679e90ba2 Merge pull request 'fix: analyzer fix' (#1027) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1027
2026-08-05 12:34:27 +00:00
misha
6b5442eb87 fix: analyzer fix 2026-08-05 15:33:27 +03:00
2474c5bea3 Merge pull request 'post_creation_with_images' (#1025) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1025
2026-08-05 12:31:23 +00:00
misha
478a53674a feat(post_creatin): fixed animation widget for profile feed 2026-08-05 15:21:23 +03:00
7f278d0f59 Merge pull request 'fix: fixed script run time location' (#1024) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1024
2026-08-04 15:36:07 +00:00
misha
53426c858c fix: fixed script run time location 2026-08-04 18:35:32 +03:00
88f0cde88f Merge pull request 'fix: added pods updating' (#1023) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1023
2026-08-04 15:19:44 +00:00
misha
a5fcf62524 fix: added pods updating 2026-08-04 18:19:10 +03:00
a2e41a2ff5 Merge pull request 'fix: removed SPM' (#1022) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1022
2026-08-04 15:13:32 +00:00
misha
5b1beafbab fix: removed SPM 2026-08-04 18:12:55 +03:00
4eadecd6f2 Merge pull request 'fix: removed spm flag' (#1021) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1021
2026-08-04 14:52:33 +00:00
misha
6292db07a1 fix: removed spm flag 2026-08-04 17:51:58 +03:00
ab29b6b329 Merge pull request 'fix: ci/cd fix' (#1020) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1020
2026-08-04 14:48:54 +00:00
misha
af5d182a79 fix: ci/cd fix 2026-08-04 17:48:08 +03:00
misha
04de956769 feat(post_creation): added multiple image posts creation and validation 2026-08-04 17:47:43 +03:00
191bb74aa4 Merge pull request 'Version changed' (#1018) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1018
2026-08-04 14:35:33 +00:00
misha
3210f38d4a Version changed 2026-08-04 14:22:33 +03:00
27a98a496d Merge pull request 'promo_codes' (#1017) from promo_codes into main
Reviewed-on: eatvio/eatvio_app#1017
2026-08-04 11:14:47 +00:00
d8ecc7f3b1 Merge pull request 'feat(promo_codes)' (#1015) from misha/eatvio_app:promo_codes into promo_codes
Reviewed-on: eatvio/eatvio_app#1015
2026-08-04 11:13:45 +00:00
063da03d7e merge upstream 2026-08-04 10:17:32 +00:00
6b5927a99e Merge pull request 'sync with main' (#1016) from main into promo_codes
Reviewed-on: eatvio/eatvio_app#1016
2026-08-04 10:17:26 +00:00
afab72f7bc merge upstream 2026-08-04 10:16:20 +00:00
2770ff0e80 Merge pull request 'feat(consultant_scoring)' (#1012) from consultant_scoring into main
Reviewed-on: eatvio/eatvio_app#1012
2026-08-04 10:16:09 +00:00
1200021bb6 Merge pull request 'personalized_feed' (#1004) from personalized_feed into main
Reviewed-on: eatvio/eatvio_app#1004
2026-08-04 10:15:25 +00:00
misha
9e8b8b89fc feat(promo_codes): added promo codes handling to app 2026-08-03 18:12:05 +03:00
a24d052193 Merge pull request 'fix: test fixed' (#1014) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1014
2026-08-03 12:32:07 +00:00
37504aa292 Merge pull request 'fix: removed unused import' (#1013) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1013
2026-08-03 08:46:16 +00:00
c8588a2e1f Merge pull request 'consultant_scoring' (#1011) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1011
2026-07-31 10:45:21 +00:00
misha
e0cab89126 Merge branch 'consultant_scoring' of ssh://10.55.5.123:222/misha/eatvio_app into consultant_scoring 2026-07-30 17:42:30 +03:00
misha
954d4955da feat(consultant_scoring): added validation errors, fixed scoring flow, updating flow 2026-07-30 17:41:30 +03:00
e5086d7fbc Merge pull request 'feat(consultant_scoring)' (#1005) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1005
2026-07-29 11:09:30 +00:00
0d93768dc3 Merge branch 'consultant_scoring' into consultant_scoring 2026-07-29 11:09:10 +00:00
misha
8c5fc8d827 feat(consultant_scoring): fixed registration update flow 2026-07-29 14:08:13 +03:00
d56aa45c92 Merge pull request 'personalized_feed' (#1009) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1009
2026-07-23 13:37:00 +00:00
misha
037f153273 feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint 2026-07-22 17:58:02 +03:00
6a17bf3a95 Merge pull request 'feat(personalized_feed): added video and multiple images handling to detailed blog post screen' (#1003) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1003
2026-07-22 11:53:41 +00:00
93 changed files with 8626 additions and 1727 deletions

View File

@@ -59,12 +59,16 @@ jobs:
- name: Flutter build - name: Flutter build
run: | run: |
flutter --version flutter --version
flutter config --no-enable-swift-package-manager
rm -rf ios/Podfile.lock
flutter clean flutter clean
flutter pub get flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs flutter pub run build_runner build --delete-conflicting-outputs
cd ios
pod install --repo-update
cd ..
flutter build ios --config-only --release flutter build ios --config-only --release
cd ios cd ios
pod install
fastlane beta fastlane beta
env: env:
LANG: en_US.UTF-8 LANG: en_US.UTF-8

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ backend/
.fvm/ .fvm/
.fvmrc .fvmrc
/eatvio /eatvio
*.code-workspace

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/models/diets_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_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart';
@@ -13,9 +14,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cub
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart'; import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget {
final Recipe? recipe; final Recipe? recipe;
final Diet? diet; final Diet? diet;
final String userSlug; final String userSlug;
final VoidCallback? onPostsChanged;
const CustomPopup({ const CustomPopup({
super.key, super.key,
@@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget {
this.feedBlogPostModel, this.feedBlogPostModel,
this.recipe, this.recipe,
this.diet, this.diet,
required this.userSlug required this.userSlug,
this.onPostsChanged,
}); });
@override @override
@@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget {
if (complainedContentType == ComplainedContent.posts) { if (complainedContentType == ComplainedContent.posts) {
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async { addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
if (complainedContentType == ComplainedContent.posts) { if (complainedContentType == ComplainedContent.posts) {
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
onPostsChanged?.call();
} }
}); });
} }
@@ -114,7 +120,14 @@ class CustomPopup extends StatelessWidget {
showErrorDialog(context, title: S.current.could_not_delete_blog_post); showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return; return;
} }
showSuccessToast('Пост удален, обновите ленту'); onPostsChanged?.call();
ProfileCubit.myProfileCubit?.reloadBlogPosts();
if (context.mounted) {
try {
context.read<FeedCubit>().loadFeedBlogPostList();
} catch (_) {}
}
showSuccessToast('Пост удален');
} else if (complainedContentType == ComplainedContent.recipe) { } else if (complainedContentType == ComplainedContent.recipe) {
final result = await sl<RecipesCubit>().deleteRecipe(slug!); final result = await sl<RecipesCubit>().deleteRecipe(slug!);
if (!result && context.mounted) { if (!result && context.mounted) {

View File

@@ -44,8 +44,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
super.initState(); super.initState();
} }
void _openGallery(List<String> imageUrls, int index) { void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
} }
@override @override
@@ -264,7 +264,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
currentGalleryIndex: currentGalleryIndex, currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery, heroTag: 'blog-post-screen-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
onGalleryChanged: (index) { onGalleryChanged: (index) {
setState(() { setState(() {
currentGalleryIndex = index; currentGalleryIndex = index;

View File

@@ -3,38 +3,184 @@ import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> { class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
final BlogPostRepository blogPostRepository; final BlogPostRepository blogPostRepository;
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState()); BlogPostCreateCubit({required this.blogPostRepository})
: super(const BlogPostCreateState());
void clearTitleError() {
if (state.titleError != null || state.formError != null) {
emit(state.copyWith(titleError: null, formError: null));
}
}
void clearDescriptionError() {
if (state.descriptionError != null || state.formError != null) {
emit(state.copyWith(descriptionError: null, formError: null));
}
}
void clearVideoUrlError() {
if (state.videoUrlError != null || state.formError != null) {
emit(state.copyWith(videoUrlError: null, formError: null));
}
}
void clearImagesError() {
if (state.imagesError != null || state.formError != null) {
emit(state.copyWith(imagesError: null, formError: null));
}
}
void resetFormErrors() {
if (!state.hasValidationErrors &&
!state.isSuccess &&
state.errorMessage == null &&
!state.isLoading) {
return;
}
emit(const BlogPostCreateState());
}
/// Validates form fields and emits error messages on [BlogPostCreateState].
/// Returns `true` when the form is valid.
bool validateForm({
required String title,
required String description,
required String videoUrl,
required int imageCount,
Iterable<String> imagePaths = const [],
}) {
final titleError = BlogPostFormValidator.title(title);
final descriptionError = BlogPostFormValidator.description(description);
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
final imagesError =
BlogPostFormValidator.galleryCount(imageCount) ??
BlogPostFormValidator.imagePaths(imagePaths);
final formError = BlogPostFormValidator.contentRequiredMessage(
title: title,
description: description,
videoUrl: videoUrl,
imageCount: imageCount,
);
final hasFieldErrors =
titleError != null ||
descriptionError != null ||
videoUrlError != null ||
imagesError != null;
final resolvedFormError = hasFieldErrors ? null : formError;
emit(
state.copyWith(
titleError: titleError,
descriptionError: descriptionError,
videoUrlError: videoUrlError,
imagesError: imagesError,
// Form-level message only when individual fields are fine but content is empty.
formError: resolvedFormError,
isSuccess: false,
errorMessage: null,
),
);
return titleError == null &&
descriptionError == null &&
videoUrlError == null &&
imagesError == null &&
resolvedFormError == null;
}
Future<bool> createBlogPost({
String? title,
String? videoUrl,
String? description,
List<XFile> images = const [],
}) async {
final trimmedTitle = title?.trim() ?? '';
final trimmedDescription = description?.trim() ?? '';
final trimmedVideoUrl = videoUrl?.trim() ?? '';
final isValid = validateForm(
title: trimmedTitle,
description: trimmedDescription,
videoUrl: trimmedVideoUrl,
imageCount: images.length,
imagePaths: images.map((image) => image.path),
);
if (!isValid) {
return false;
}
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
try { try {
emit(const BlogPostCreateState.loading()); emit(
await blogPostRepository.createBlogPost(title, videoUrl, description, image); state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
emit(const BlogPostCreateState.loaded()); );
await blogPostRepository.createBlogPost(
title: trimmedTitle.isEmpty ? null : trimmedTitle,
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
description: trimmedDescription.isEmpty ? null : trimmedDescription,
images: images,
);
emit(const BlogPostCreateState(isSuccess: true));
return true; return true;
} catch (error, stackTrace) { } catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace); log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this); await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState.error(errorMessage: error.toString())); emit(BlogPostCreateState(errorMessage: error.toString()));
return false; return false;
} }
} }
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async { Future<bool> updateBlogPost({
required int id,
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
List<int> mediaIdsToDelete = const [],
}) async {
final trimmedTitle = title?.trim() ?? '';
final trimmedDescription = description?.trim() ?? '';
final trimmedVideoUrl = videoUrl?.trim() ?? '';
final imageCount = existingMediaIds.length + newImages.length;
final isValid = validateForm(
title: trimmedTitle,
description: trimmedDescription,
videoUrl: trimmedVideoUrl,
imageCount: imageCount,
imagePaths: newImages.map((image) => image.path),
);
if (!isValid) {
return false;
}
try { try {
emit(const BlogPostCreateState.loading()); emit(
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl); state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
emit(const BlogPostCreateState.loaded()); );
await blogPostRepository.updateBlogPost(
id: id,
title: trimmedTitle,
videoUrl: trimmedVideoUrl,
description: trimmedDescription,
existingMediaIds: existingMediaIds,
newImages: newImages,
mediaIdsToDelete: mediaIdsToDelete,
);
emit(const BlogPostCreateState(isSuccess: true));
return true; return true;
} catch (error, stackTrace) { } catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace); log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this); await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState.error(errorMessage: error.toString())); emit(BlogPostCreateState(errorMessage: error.toString()));
return false; return false;
} }
} }

View File

@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
@freezed @freezed
abstract class BlogPostCreateState with _$BlogPostCreateState { abstract class BlogPostCreateState with _$BlogPostCreateState {
const factory BlogPostCreateState() = _BlogPostCreateState; const BlogPostCreateState._();
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading; const factory BlogPostCreateState({
@Default(false) bool isLoading,
@Default(false) bool isSuccess,
String? errorMessage,
String? titleError,
String? descriptionError,
String? videoUrlError,
String? imagesError,
String? formError,
}) = _BlogPostCreateState;
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded; bool get hasValidationErrors =>
titleError != null ||
const factory BlogPostCreateState.error({ descriptionError != null ||
required String errorMessage, videoUrlError != null ||
}) = BlogPostCreateStateError; imagesError != null ||
formError != null;
} }

View File

@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
import 'package:eatvio_app/core/theme/app_decoration.dart'; import 'package:eatvio_app/core/theme/app_decoration.dart';
@@ -12,8 +12,13 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.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/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.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/models/post_feed_pagination_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_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -36,20 +41,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
final blogPostCubit = sl<BlogPostCreateCubit>(); final blogPostCubit = sl<BlogPostCreateCubit>();
bool shrinkImage = false; bool shrinkImage = false;
late StreamSubscription<bool> keyboardSubscription; late StreamSubscription<bool> keyboardSubscription;
XFile? image; final List<FeedGalleryImage> savedImages = [];
final List<XFile> pendingImages = [];
final List<int> mediaIdsToDelete = [];
final titleController = TextEditingController(); final titleController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
final videoUrlController = TextEditingController(); final videoUrlController = TextEditingController();
bool isLoading = false;
bool enableMdPanel = false; bool enableMdPanel = false;
bool isPreviewMode = false; bool isPreviewMode = false;
final FocusNode descriptionFocusNode = FocusNode(); final FocusNode descriptionFocusNode = FocusNode();
bool get _isEditMode => widget.feedBlogPostModel != null;
int get _galleryCount => savedImages.length + pendingImages.length;
int get _remainingSlots =>
BlogPostRepository.maxGalleryImages - _galleryCount;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
blogPostCubit.resetFormErrors();
var keyboardVisibilityController = KeyboardVisibilityController(); var keyboardVisibilityController = KeyboardVisibilityController();
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) { keyboardSubscription = keyboardVisibilityController.onChange.listen((
bool visible,
) {
setState(() { setState(() {
shrinkImage = visible; shrinkImage = visible;
enableMdPanel = visible; enableMdPanel = visible;
@@ -59,6 +75,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
titleController.text = widget.feedBlogPostModel!.title; titleController.text = widget.feedBlogPostModel!.title;
descriptionController.text = widget.feedBlogPostModel!.description; descriptionController.text = widget.feedBlogPostModel!.description;
videoUrlController.text = widget.feedBlogPostModel!.videoUrl; videoUrlController.text = widget.feedBlogPostModel!.videoUrl;
savedImages.addAll(
widget.feedBlogPostModel!.images.where((image) => image.id != null),
);
} }
} }
@@ -66,6 +85,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
void dispose() { void dispose() {
keyboardSubscription.cancel(); keyboardSubscription.cancel();
descriptionFocusNode.dispose(); descriptionFocusNode.dispose();
titleController.dispose();
descriptionController.dispose();
videoUrlController.dispose();
super.dispose(); super.dispose();
} }
@@ -78,9 +100,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix); return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
} else { } else {
final cursorPosition = selection.baseOffset; final cursorPosition = selection.baseOffset;
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) { if (cursorPosition >= prefix.length &&
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition); text.length >= cursorPosition + suffix.length) {
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length); final beforeCursor = text.substring(
cursorPosition - prefix.length,
cursorPosition,
);
final afterCursor = text.substring(
cursorPosition,
cursorPosition + suffix.length,
);
return beforeCursor == prefix && afterCursor == suffix; return beforeCursor == prefix && afterCursor == suffix;
} }
} }
@@ -93,23 +122,36 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
if (selection.isValid && !selection.isCollapsed) { if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end); final selectedText = text.substring(selection.start, selection.end);
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length); final unformattedText = selectedText.substring(
prefix.length,
selectedText.length - suffix.length,
);
final newText = text.replaceRange(selection.start, selection.end, unformattedText); final newText = text.replaceRange(
selection.start,
selection.end,
unformattedText,
);
descriptionController.text = newText; descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length); final newSelection = TextSelection(
baseOffset: selection.start,
extentOffset: selection.start + unformattedText.length,
);
descriptionController.selection = newSelection; descriptionController.selection = newSelection;
} else { } else {
final cursorPosition = selection.baseOffset; final cursorPosition = selection.baseOffset;
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) { if (cursorPosition >= prefix.length &&
text.length >= cursorPosition + suffix.length) {
final beforeCursor = text.substring(0, cursorPosition - prefix.length); final beforeCursor = text.substring(0, cursorPosition - prefix.length);
final afterCursor = text.substring(cursorPosition + suffix.length); final afterCursor = text.substring(cursorPosition + suffix.length);
final newText = beforeCursor + afterCursor; final newText = beforeCursor + afterCursor;
descriptionController.text = newText; descriptionController.text = newText;
final newCursorPosition = cursorPosition - prefix.length; final newCursorPosition = cursorPosition - prefix.length;
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); descriptionController.selection = TextSelection.collapsed(
offset: newCursorPosition,
);
} }
} }
} }
@@ -122,18 +164,28 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
final selectedText = text.substring(selection.start, selection.end); final selectedText = text.substring(selection.start, selection.end);
final formattedText = '$prefix$selectedText$suffix'; final formattedText = '$prefix$selectedText$suffix';
final newText = text.replaceRange(selection.start, selection.end, formattedText); final newText = text.replaceRange(
selection.start,
selection.end,
formattedText,
);
descriptionController.text = newText; descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length); final newSelection = TextSelection(
baseOffset: selection.start,
extentOffset: selection.start + formattedText.length,
);
descriptionController.selection = newSelection; descriptionController.selection = newSelection;
} else { } else {
final cursorPosition = selection.baseOffset; final cursorPosition = selection.baseOffset;
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}'; final newText =
'${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
descriptionController.text = newText; descriptionController.text = newText;
final newCursorPosition = cursorPosition + prefix.length; final newCursorPosition = cursorPosition + prefix.length;
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); descriptionController.selection = TextSelection.collapsed(
offset: newCursorPosition,
);
} }
} }
@@ -188,17 +240,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
if (linkRegex.hasMatch(selectedText)) { if (linkRegex.hasMatch(selectedText)) {
final match = linkRegex.firstMatch(selectedText); final match = linkRegex.firstMatch(selectedText);
final linkText = match?.group(1) ?? selectedText; final linkText = match?.group(1) ?? selectedText;
final newText = text.replaceRange(selection.start, selection.end, linkText); final newText = text.replaceRange(
selection.start,
selection.end,
linkText,
);
descriptionController.text = newText; descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length); final newSelection = TextSelection(
baseOffset: selection.start,
extentOffset: selection.start + linkText.length,
);
descriptionController.selection = newSelection; descriptionController.selection = newSelection;
} else { } else {
final formattedText = '[$selectedText](url)'; final formattedText = '[$selectedText](url)';
final newText = text.replaceRange(selection.start, selection.end, formattedText); final newText = text.replaceRange(
selection.start,
selection.end,
formattedText,
);
descriptionController.text = newText; descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5); final newSelection = TextSelection(
baseOffset: selection.start + selectedText.length + 2,
extentOffset: selection.start + selectedText.length + 5,
);
descriptionController.selection = newSelection; descriptionController.selection = newSelection;
} }
} else { } else {
@@ -209,11 +275,14 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
void _applyHorizontalRule() { void _applyHorizontalRule() {
final cursorPosition = descriptionController.selection.baseOffset; final cursorPosition = descriptionController.selection.baseOffset;
final text = descriptionController.text; final text = descriptionController.text;
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}'; final newText =
'${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
descriptionController.text = newText; descriptionController.text = newText;
final newCursorPosition = cursorPosition + 5; final newCursorPosition = cursorPosition + 5;
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); descriptionController.selection = TextSelection.collapsed(
offset: newCursorPosition,
);
} }
void _togglePreview() { void _togglePreview() {
@@ -240,7 +309,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE0E0E0)), border: Border.all(color: const Color(0xFFE0E0E0)),
), ),
child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)), child: SingleChildScrollView(
child: _parseMarkdown(descriptionController.text),
),
); );
} }
@@ -248,7 +319,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
if (text.isEmpty) { if (text.isEmpty) {
return const Text( return const Text(
'Описание', 'Описание',
style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500), style: TextStyle(
fontSize: 16,
color: Color(0xFF323F4B),
fontWeight: FontWeight.w500,
),
); );
} }
@@ -267,21 +342,33 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
widgets.add( widgets.add(
Text( Text(
line.substring(2), line.substring(2),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black), style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black,
),
), ),
); );
} else if (line.startsWith('## ')) { } else if (line.startsWith('## ')) {
widgets.add( widgets.add(
Text( Text(
line.substring(3), line.substring(3),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black,
),
), ),
); );
} else if (line.startsWith('### ')) { } else if (line.startsWith('### ')) {
widgets.add( widgets.add(
Text( Text(
line.substring(4), line.substring(4),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black,
),
), ),
); );
} else if (line.startsWith('- ') || line.startsWith('* ')) { } else if (line.startsWith('- ') || line.startsWith('* ')) {
@@ -307,7 +394,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('$number. ', style: const TextStyle(fontSize: 16)), Text('$number. ', style: const TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))), Expanded(
child: _parseInlineMarkdown(
line.substring(number.length + 2),
),
),
], ],
), ),
), ),
@@ -323,7 +414,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
} }
} }
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets); return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
);
} }
Widget _parseInlineMarkdown(String text) { Widget _parseInlineMarkdown(String text) {
@@ -347,14 +441,22 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
spans.add( spans.add(
TextSpan( TextSpan(
text: matchedText.substring(2, matchedText.length - 2), text: matchedText.substring(2, matchedText.length - 2),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black), style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black,
),
), ),
); );
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) { } else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
spans.add( spans.add(
TextSpan( TextSpan(
text: matchedText.substring(1, matchedText.length - 1), text: matchedText.substring(1, matchedText.length - 1),
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black), style: const TextStyle(
fontSize: 16,
fontStyle: FontStyle.italic,
color: Colors.black,
),
), ),
); );
} else if (matchedText.startsWith('[') && matchedText.contains('](')) { } else if (matchedText.startsWith('[') && matchedText.contains('](')) {
@@ -364,7 +466,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
spans.add( spans.add(
TextSpan( TextSpan(
text: linkText, text: linkText,
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline), style: const TextStyle(
fontSize: 16,
color: Colors.blue,
decoration: TextDecoration.underline,
),
), ),
); );
} }
@@ -390,9 +496,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
return BlocProvider<BlogPostCreateCubit>( return BlocProvider<BlogPostCreateCubit>(
create: (BuildContext context) => blogPostCubit, create: (BuildContext context) => blogPostCubit,
child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>( child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
bloc: blogPostCubit,
listener: (BuildContext context, BlogPostCreateState state) { listener: (BuildContext context, BlogPostCreateState state) {
if (state is BlogPostCreateStateLoaded) { if (state.isSuccess) {
context.read<FeedCubit>().loadFeedBlogPostList(); context.read<FeedCubit>().loadFeedBlogPostList();
ProfileCubit.myProfileCubit?.reloadBlogPosts();
} }
}, },
child: _buildBlogPostCreate(context), child: _buildBlogPostCreate(context),
@@ -401,6 +509,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
} }
void _showImagePicker() { void _showImagePicker() {
if (_remainingSlots <= 0) {
return;
}
showCupertinoModalPopup<void>( showCupertinoModalPopup<void>(
context: context, context: context,
builder: (context) { builder: (context) {
@@ -415,14 +527,27 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
description: s.allowAccessToCameraDescription, description: s.allowAccessToCameraDescription,
onGranted: () async { onGranted: () async {
final picker = ImagePicker(); final picker = ImagePicker();
image = await picker.pickImage(source: ImageSource.camera); final image = await picker.pickImage(
setState(() {}); source: ImageSource.camera,
);
if (image != null && _remainingSlots > 0) {
setState(() {
pendingImages.add(image);
});
blogPostCubit.clearImagesError();
}
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
}, },
context: context, context: context,
); );
}, },
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), child: const Row(
children: [
Icon(Icons.photo_camera),
SizedBox(width: 8),
Text('Фото'),
],
),
), ),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () async { onPressed: () async {
@@ -432,30 +557,88 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
description: s.allowAcessToGaleryDescription, description: s.allowAcessToGaleryDescription,
onGranted: () async { onGranted: () async {
final picker = ImagePicker(); final picker = ImagePicker();
image = await picker.pickImage(source: ImageSource.gallery); final images = await picker.pickMultiImage(
setState(() {}); limit: _remainingSlots,
);
if (images.isNotEmpty) {
setState(() {
pendingImages.addAll(images.take(_remainingSlots));
});
blogPostCubit.clearImagesError();
}
if (!context.mounted) return; if (!context.mounted) return;
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
}, },
context: context, context: context,
); );
}, },
child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]), child: const Row(
children: [
Icon(Icons.photo),
SizedBox(width: 8),
Text('Галерея'),
],
),
), ),
], ],
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')), cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(),
isDestructiveAction: true,
child: const Text('Отмена'),
),
); );
}, },
); );
} }
void _removeSavedImage(int index) {
final image = savedImages[index];
final mediaId = image.id;
setState(() {
savedImages.removeAt(index);
if (mediaId != null) {
mediaIdsToDelete.add(mediaId);
}
});
}
void _removePendingImage(int index) {
setState(() {
pendingImages.removeAt(index);
});
}
void _onReorderGallery(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
if (oldIndex < savedImages.length && newIndex < savedImages.length) {
final item = savedImages.removeAt(oldIndex);
savedImages.insert(newIndex, item);
return;
}
final pendingOld = oldIndex - savedImages.length;
final pendingNew = newIndex - savedImages.length;
if (pendingOld >= 0 && pendingNew >= 0) {
final item = pendingImages.removeAt(pendingOld);
pendingImages.insert(pendingNew, item);
}
});
}
Widget _mdButton(IconData icon, Function() onTap) { Widget _mdButton(IconData icon, Function() onTap) {
return Expanded( return Expanded(
child: GestureDetector( child: GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
height: 36, height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(
color: const Color(0xFFF1F2F6),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon), child: Icon(icon),
), ),
), ),
@@ -468,11 +651,18 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
height: 36, height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(
color: const Color(0xFFF1F2F6),
borderRadius: BorderRadius.circular(10),
),
child: Center( child: Center(
child: Text( child: Text(
text, text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.black87,
),
), ),
), ),
), ),
@@ -496,7 +686,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)), BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
], ],
), ),
child: Row( child: Row(
@@ -508,9 +703,15 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic), _mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8), const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList), _mdButton(
Icons.format_list_numbered,
_applyNumberedList,
),
const SizedBox(width: 8), const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList), _mdButton(
Icons.format_list_bulleted,
_applyBulletedList,
),
const SizedBox(width: 8), const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink), _mdButton(Icons.link, _applyLink),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -528,157 +729,297 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))], boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
_mdButton(
isPreviewMode ? Icons.edit : Icons.visibility,
_togglePreview,
),
],
), ),
child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]),
), ),
], ],
) )
: null, : null,
appBar: AppBar(), appBar: AppBar(),
body: SingleChildScrollView( body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
padding: const EdgeInsets.all(16), bloc: blogPostCubit,
builder: (context, state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField,
controller: titleController,
hasError: state.titleError != null,
onChanged: (_) => blogPostCubit.clearTitleError(),
),
if (state.titleError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.titleError!),
],
const SizedBox(height: 16),
const Text('Описание'),
const SizedBox(height: 8),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
hasError: state.descriptionError != null,
onChanged: (_) => blogPostCubit.clearDescriptionError(),
),
if (state.descriptionError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.descriptionError!),
],
const SizedBox(height: 16),
Text(
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
),
const SizedBox(height: 8),
_buildGallery(),
if (state.imagesError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.imagesError!),
],
const SizedBox(height: 16),
const Text('Ссылка на видео'),
const SizedBox(height: 4),
Text(
'Rutube, VK или OK',
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
controller: videoUrlController,
hasError: state.videoUrlError != null,
keyboardType: TextInputType.url,
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
),
if (state.videoUrlError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.videoUrlError!),
],
if (state.formError != null) ...[
const SizedBox(height: 16),
ValidationErrorWidget(error: state.formError!),
],
const SizedBox(height: 80),
_createBlogPostButton(isSubmitting: state.isLoading),
const SizedBox(height: 80),
],
),
);
},
),
);
}
Future<void> _submitBlogPost() async {
if (blogPostCubit.state.isLoading) {
return;
}
final title = titleController.text.trim();
final description = descriptionController.text.trim();
final videoUrl = videoUrlController.text.trim();
final existingMediaIds = savedImages.map((image) => image.id!).toList();
final bool result;
if (_isEditMode) {
result = await blogPostCubit.updateBlogPost(
id: widget.feedBlogPostModel!.id,
title: title,
description: description,
videoUrl: videoUrl,
existingMediaIds: existingMediaIds,
newImages: pendingImages,
mediaIdsToDelete: List<int>.from(mediaIdsToDelete),
);
} else {
result = await blogPostCubit.createBlogPost(
title: title,
description: description,
videoUrl: videoUrl,
images: pendingImages,
);
}
if (!result) {
if (!mounted) return;
if (blogPostCubit.state.hasValidationErrors) {
return;
}
showErrorDialog(
context,
title: _isEditMode
? S.current.could_not_update_blog_post
: S.current.could_not_publish_blog_post,
);
} else {
if (!mounted) return;
if (context.canPop()) {
context.pop();
}
}
}
Widget _createBlogPostButton({required bool isSubmitting}) {
return InkWell(
key: AppKeys.blogPostCreateScreenCreateButton,
onTap: isSubmitting ? null : _submitBlogPost,
borderRadius: BorderRadius.circular(16),
child: isSubmitting
? const Center(child: Loader())
: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: appTheme.blueA400,
),
child: Center(
child: Text(
_isEditMode ? 'Сохранить' : 'Создать',
style: const TextStyle(color: Colors.white),
),
),
),
);
}
Widget _buildGallery() {
final itemHeight = shrinkImage ? 72.0 : 112.0;
return SizedBox(
height: itemHeight,
child: ReorderableListView.builder(
scrollDirection: Axis.horizontal,
buildDefaultDragHandles: false,
onReorder: _onReorderGallery,
itemCount: _galleryCount + (_remainingSlots > 0 ? 1 : 0),
itemBuilder: (context, index) {
if (index == _galleryCount) {
return KeyedSubtree(
key: const ValueKey('add-photo'),
child: _buildAddPhotoTile(itemHeight),
);
}
if (index < savedImages.length) {
final image = savedImages[index];
final url = PostFeedMapper.resolveMediaUrl(image.url);
return KeyedSubtree(
key: ValueKey('saved-${image.id}'),
child: _buildGalleryTile(
index: index,
height: itemHeight,
imageProvider: NetworkImage(url),
onRemove: () => _removeSavedImage(index),
),
);
}
final pendingIndex = index - savedImages.length;
final pending = pendingImages[pendingIndex];
return KeyedSubtree(
key: ValueKey('pending-${pending.path}'),
child: _buildGalleryTile(
index: index,
height: itemHeight,
imageProvider: FileImage(File(pending.path)),
onRemove: () => _removePendingImage(pendingIndex),
),
);
},
),
);
}
Widget _buildAddPhotoTile(double height) {
return GestureDetector(
onTap: _showImagePicker,
child: Container(
width: height,
height: height,
margin: const EdgeInsets.only(right: 8),
decoration: AppDecoration.fillGray.copyWith(
borderRadius: BorderRadius.circular(16),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 16), Icon(Icons.photo_library, color: appTheme.blueGray300),
const Text('Заголовок'), const SizedBox(height: 4),
const SizedBox(height: 8), Text(
CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController), 'Добавить',
const SizedBox(height: 16), style: TextStyle(fontSize: 12, color: appTheme.blueGray300),
const Text('Описание'), ),
const SizedBox(height: 8),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
),
const SizedBox(height: 16),
if (videoUrlController.text.isEmpty) const Text('Изображение поста'),
if (videoUrlController.text.isEmpty) const SizedBox(height: 8),
if (videoUrlController.text.isEmpty) _buildAddPhoto(),
const SizedBox(height: 16),
if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'),
if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8),
if (image == null && widget.feedBlogPostModel?.image == null)
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
const SizedBox(height: 80),
_createBlogPostButton(),
const SizedBox(height: 80),
], ],
), ),
), ),
); );
} }
Widget _createBlogPostButton() { Widget _buildGalleryTile({
return InkWell( required int index,
onTap: isLoading required double height,
? null required ImageProvider imageProvider,
: () async { required VoidCallback onRemove,
setState(() { }) {
isLoading = true; return ReorderableDelayedDragStartListener(
}); index: index,
if (widget.feedBlogPostModel != null) { child: Container(
final result = await blogPostCubit.updateBlogPost( width: height,
id: widget.feedBlogPostModel!.id, height: height,
title: titleController.text, margin: const EdgeInsets.only(right: 8),
description: descriptionController.text, child: Stack(
videoUrl: videoUrlController.text, clipBehavior: Clip.none,
image: image, children: [
oldImageUrl: widget.feedBlogPostModel!.image, Positioned.fill(
);
setState(() {
isLoading = false;
});
if (!result) {
if (!mounted) return;
showErrorDialog(context, title: S.current.could_not_update_blog_post);
} else {
if (!mounted) return;
if (context.canPop()) {
context.pop();
}
}
} else {
final result = await blogPostCubit.createBlogPost(
title: titleController.text,
description: descriptionController.text,
videoUrl: videoUrlController.text,
image: image,
);
setState(() {
isLoading = false;
});
if (!result) {
if (!mounted) return;
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
} else {
if (!mounted) return;
if (context.canPop()) {
context.pop();
}
}
}
},
borderRadius: BorderRadius.circular(16),
child: isLoading
? const Center(child: Loader())
: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
child: const Center(
child: Text('Создать', style: TextStyle(color: Colors.white)),
),
),
);
}
GestureDetector _buildAddPhoto() {
return GestureDetector(
onTap: () {
_showImagePicker();
},
child: Stack(
children: [
if (image == null && widget.feedBlogPostModel?.image == null)
AnimatedContainer(
height: shrinkImage ? 52 : 128,
curve: Curves.easeIn,
duration: const Duration(milliseconds: 150),
decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.photo_library, color: appTheme.blueGray300),
const SizedBox(width: 8),
Text("Добавить фото", selectionColor: appTheme.blueA400),
],
),
),
if (image != null)
SizedBox(
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Image(image: FileImage(File(image!.path))), child: Image(image: imageProvider, fit: BoxFit.cover),
), ),
), ),
if (widget.feedBlogPostModel?.image != null && image == null) Positioned(
SizedBox( top: -6,
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32, right: -6,
child: ClipRRect( child: Material(
borderRadius: BorderRadius.circular(16), color: Colors.white,
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')), shape: const CircleBorder(),
elevation: 1,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onRemove,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.close, size: 16),
),
),
), ),
), ),
], ],
),
), ),
); );
} }

View File

@@ -1,79 +1,122 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:http/http.dart' as http;
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart'; import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path_provider/path_provider.dart';
class BlogPostRepository { class BlogPostRepository {
static const maxGalleryImages = 10;
final BlogPostService blogPostService; final BlogPostService blogPostService;
BlogPostRepository({ BlogPostRepository({required this.blogPostService});
required this.blogPostService,
});
Future<void> createBlogPost( Future<void> createBlogPost({
String? title, String? title,
String? videoUrl, String? videoUrl,
String? description, String? description,
XFile? image, List<XFile> images = const [],
) async { }) async {
final formData = FormData.fromMap({ final formData = await _buildFormData(
if (title != null && title.isNotEmpty) 'title': title, title: title,
if (description != null && description.isNotEmpty) 'description_md': description, videoUrl: videoUrl,
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl, description: description,
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name), newImages: images,
});
return blogPostService.createBlogPost(
formData: formData,
); );
return blogPostService.createBlogPost(formData: formData);
} }
Future<XFile?> downloadImage(String imageUrl) async { Future<void> updateBlogPost({
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl')); required int id,
String? title,
if (response.statusCode == 200) { String? videoUrl,
final directory = await getTemporaryDirectory(); String? description,
final filePath = '${directory.path}/image.jpg'; List<int> existingMediaIds = const [],
final file = File(filePath); List<XFile> newImages = const [],
await file.writeAsBytes(response.bodyBytes); List<int> mediaIdsToDelete = const [],
}) async {
return XFile(file.path); if (mediaIdsToDelete.isNotEmpty) {
await Future.wait(
mediaIdsToDelete.map(
(mediaId) =>
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
),
);
} }
return null;
final formData = await _buildFormData(
title: title ?? '',
videoUrl: videoUrl ?? '',
description: description ?? '',
existingMediaIds: existingMediaIds,
newImages: newImages,
includeExistingMediaIds: true,
includeEmptyTextFields: true,
);
return blogPostService.updateBlogPost(id: id, formData: formData);
} }
Future<void> updateBlogPost( Future<void> deleteBlogPostMedia({
int id, required int postId,
String? title, required int mediaId,
String? videoUrl, }) async {
String? description, await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
XFile? image,
String? oldImageUrl,
) async {
final formData = FormData.fromMap({
'title': title,
'description_md': description,
'video_url': videoUrl,
'image': image != null
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
: oldImageUrl != '' && oldImageUrl != null
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
: null,
});
return blogPostService.updateBlogPost(
id: id,
formData: formData,
);
} }
Future<void> deleteBlogPost(int id) async { Future<void> deleteBlogPost(int id) async {
await blogPostService.deleteBlogPost(id: id); await blogPostService.deleteBlogPost(id: id);
} }
Future<FormData> _buildFormData({
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
bool includeExistingMediaIds = false,
bool includeEmptyTextFields = false,
}) async {
final formData = FormData();
if (includeEmptyTextFields) {
formData.fields.add(MapEntry('title', title ?? ''));
formData.fields.add(MapEntry('description_md', description ?? ''));
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
} else {
if (title != null && title.isNotEmpty) {
formData.fields.add(MapEntry('title', title));
}
if (description != null && description.isNotEmpty) {
formData.fields.add(MapEntry('description_md', description));
}
if (videoUrl != null && videoUrl.isNotEmpty) {
formData.fields.add(MapEntry('video_url', videoUrl));
}
}
if (includeExistingMediaIds) {
for (final mediaId in existingMediaIds) {
formData.fields.add(
MapEntry('existing_media_ids[]', mediaId.toString()),
);
}
}
for (final image in newImages) {
final compressed = await ImageCompressUtility.compressFile(
File(image.path),
);
formData.files.add(
MapEntry(
'images[]',
await MultipartFile.fromFile(compressed.path, filename: image.name),
),
);
}
return formData;
}
} }

View File

@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
abstract class BlogPostService { abstract class BlogPostService {
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService; factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
@POST('/api/blogPosts/create/published') @POST('/api/v2/blogPosts/create/published')
@MultiPart() @MultiPart()
Future<void> createBlogPost({ Future<void> createBlogPost({@Body() required FormData formData});
@Body() required FormData formData,
});
@POST('/api/blogPosts/update/published/{id}') @PUT('/api/v2/blogPosts/update/published/{id}')
@MultiPart() @MultiPart()
Future<void> updateBlogPost({ Future<void> updateBlogPost({
@Path('id') required int id, @Path('id') required int id,
@Body() required FormData formData, @Body() required FormData formData,
}); });
@DELETE('/api/blogPosts/delete/{id}') @DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
Future<void> deleteBlogPost({ Future<void> deleteBlogPostMedia({
@Path('id') required int id, @Path('postId') required int postId,
@Path('mediaId') required int mediaId,
}); });
@DELETE('/api/blogPosts/delete/{id}')
Future<void> deleteBlogPost({@Path('id') required int id});
} }

View File

@@ -0,0 +1,158 @@
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
abstract final class BlogPostFormValidator {
static const minTextLength = 3;
static const maxVideoUrlLength = 2048;
static const maxGalleryImages = 10;
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
static const descriptionTooShort =
'Описание должно содержать минимум 3 символа';
static const contentRequired =
'Добавьте заголовок, описание, изображение или ссылку на видео';
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
static const videoUrlUnsupportedHost =
'Поддерживаются только Rutube, VK и OK';
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
static String? title(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return null;
}
if (text.length < minTextLength) {
return titleTooShort;
}
return null;
}
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
static String? description(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return null;
}
if (text.length < minTextLength) {
return descriptionTooShort;
}
return null;
}
/// Empty is allowed; non-empty must match allowed video hosts/paths.
static String? videoUrl(String? value) {
final url = value?.trim() ?? '';
if (url.isEmpty) {
return null;
}
if (url.length > maxVideoUrlLength) {
return videoUrlTooLong;
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return videoUrlInvalid;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return videoUrlInvalid;
}
final host = uri.host.toLowerCase();
if (!_hostMatchesRootAllowlist(host, const [
'vk.com',
'vkvideo.ru',
'ok.ru',
'rutube.ru',
])) {
return videoUrlUnsupportedHost;
}
final path = uri.path;
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
if (!_rutubePath.hasMatch(path)) {
return videoUrlRutubeFormat;
}
return null;
}
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
if (!_vkPath.hasMatch(path)) {
return videoUrlVkFormat;
}
return null;
}
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
if (!_okPath.hasMatch(path)) {
return videoUrlOkFormat;
}
}
return null;
}
/// At least one of title / description / images / video must be meaningfully filled.
static String? contentRequiredMessage({
required String title,
required String description,
required String videoUrl,
required int imageCount,
}) {
final hasTitle = title.trim().length >= minTextLength;
final hasDescription = description.trim().length >= minTextLength;
final trimmedVideo = videoUrl.trim();
final hasVideo =
trimmedVideo.isNotEmpty &&
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
final hasImages = imageCount > 0;
if (hasTitle || hasDescription || hasVideo || hasImages) {
return null;
}
return contentRequired;
}
static String? galleryCount(int count) {
if (count > maxGalleryImages) {
return galleryTooLarge;
}
return null;
}
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
static String? imagePaths(Iterable<String> paths) {
for (final path in paths) {
final name = path.split(RegExp(r'[\\/]')).last;
final dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length - 1) {
return imageTypeInvalid;
}
final ext = name.substring(dot + 1).toLowerCase();
if (!allowedImageExtensions.contains(ext)) {
return imageTypeInvalid;
}
}
return null;
}
static final _rutubePath = RegExp(
r'^/(video|shorts)/[a-z0-9]+/?$',
caseSensitive: false,
);
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
static final _okPath = RegExp(r'^/video/\d+/?$');
static bool _hostMatchesRootAllowlist(
String host,
List<String> allowedRootHosts,
) {
for (final allowed in allowedRootHosts) {
if (host == allowed || host.endsWith('.$allowed')) {
return true;
}
}
return false;
}
}

View File

@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
final int index; final int index;
final bool isLocal; final bool isLocal;
/// When set, used instead of the image URL so source/destination tags stay unique.
final Object? heroTag;
const PhotoViewScreen({ const PhotoViewScreen({
super.key, super.key,
required this.imageUrls, required this.imageUrls,
required this.index, required this.index,
this.isLocal = false, this.isLocal = false,
this.heroTag,
}); });
@override @override
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
super.initState(); super.initState();
} }
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
if (widget.heroTag != null) {
if (index != widget.index) {
return null;
}
return PhotoViewHeroAttributes(
tag: widget.heroTag!,
transitionOnUserGestures: true,
);
}
return PhotoViewHeroAttributes(
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DismissiblePage( return DismissiblePage(
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
direction: DismissiblePageDismissDirection.vertical, direction: DismissiblePageDismissDirection.vertical,
isFullScreen: true, isFullScreen: true,
child: PhotoViewGallery.builder( child: PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration( backgroundDecoration: const BoxDecoration(color: Colors.transparent),
color: Colors.transparent,
),
scrollPhysics: const BouncingScrollPhysics(), scrollPhysics: const BouncingScrollPhysics(),
builder: (BuildContext context, int index) { builder: (BuildContext context, int index) {
final imageProvider = widget.isLocal final imageProvider = widget.isLocal
? FileImage(widget.imageUrls[index]) as ImageProvider<Object> ? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>; : CachedNetworkImageProvider(widget.imageUrls[index])
as ImageProvider<Object>;
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: imageProvider, imageProvider: imageProvider,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes( heroAttributes: _heroAttributesFor(index),
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
),
errorBuilder: (context, object, stackTrace) { errorBuilder: (context, object, stackTrace) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
), ),
contentPadding: const EdgeInsets.all(16), contentPadding: const EdgeInsets.all(16),
onTap: () async { onTap: () async {
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) { if (!await launchUrl(
throw Exception('Could not launch ${widget.imageUrls[index]}'); Uri.parse(widget.imageUrls[index]),
)) {
throw Exception(
'Could not launch ${widget.imageUrls[index]}',
);
} }
}, },
leading: const Icon( leading: const Icon(
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
color: Colors.white, color: Colors.white,
), ),
title: Text( title: Text(
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''), widget.imageUrls[index].replaceAll(
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
'',
),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 12, fontSize: 12,
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
width: 20.0, width: 20.0,
height: 20.0, height: 20.0,
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!, value: event == null
? 0
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
), ),
), ),
), ),

View File

@@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:retrofit/retrofit.dart'; import 'package:retrofit/retrofit.dart';
@@ -69,6 +73,26 @@ abstract class ChatService {
@Body() required Map<String, String> body, @Body() required Map<String, String> body,
}); });
@GET('/api/chosen_plans/applied_promos')
Future<PromoAppliedListResponse> listAppliedPromoCodes();
@POST('/api/chosen_plans/{id}/validate_promo')
Future<PromoValidateResponse> validatePromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@POST('/api/chosen_plans/{id}/apply_promo')
Future<PromoApplyResponse> applyPromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
Future<PromoRemoveResponse> removePromoCode({
@Path() required int id,
});
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}') @GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
Future<OrderIdToConversationModel> resolveOrderIdToConversation({ Future<OrderIdToConversationModel> resolveOrderIdToConversation({
@Path() required String orderId, @Path() required String orderId,

View File

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

View File

@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
const ConsultantCertPhotoModel._(); 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 visibleGroup = 'user_certs';
static const String hiddenGroup = 'hidden_user_certs'; static const String hiddenGroup = 'hidden_user_certs';

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'consultant_profile_model.freezed.dart'; part 'consultant_profile_model.freezed.dart';
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
@freezed @freezed
abstract class ConsultantProfileModel with _$ConsultantProfileModel { abstract class ConsultantProfileModel with _$ConsultantProfileModel {
const ConsultantProfileModel._();
const factory ConsultantProfileModel({ const factory ConsultantProfileModel({
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, @JsonKey(name: 'has_medical_education', includeIfNull: false)
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, bool? hasMedicalEducation,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos, @JsonKey(name: 'social_links', includeIfNull: false)
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates, 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; }) = _ConsultantProfileModel;
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json); factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
_$ConsultantProfileModelFromJson(json);
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
} }
@freezed @freezed
abstract class SocialLinksModel with _$SocialLinksModel { abstract class SocialLinksModel with _$SocialLinksModel {
const factory SocialLinksModel({ const factory SocialLinksModel({
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, @JsonKey(name: 'personal_site_link', includeIfNull: false)
String? personalSiteLink,
}) = _SocialLinksModel; }) = _SocialLinksModel;
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json); factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
_$SocialLinksModelFromJson(json);
} }
@freezed @freezed
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
required String date, required String date,
}) = _CertificatesModel; }) = _CertificatesModel;
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json); factory CertificatesModel.fromJson(Map<String, Object?> json) =>
_$CertificatesModelFromJson(json);
} }

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.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: 'rating_place') int? ratingPlace,
@JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'education_resource') required EducationModel educationModel,
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos, @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; }) = _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 @freezed
abstract class CertPhotosModel with _$CertPhotosModel { abstract class CertPhotosModel with _$CertPhotosModel {
const factory CertPhotosModel({ const factory CertPhotosModel({required String url}) = _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 { enum ModerationStatus { approved, created, canceled }
approved,
created,
canceled
}
@freezed @freezed
abstract class CanceledModerationModel with _$CanceledModerationModel { abstract class CanceledModerationModel with _$CanceledModerationModel {
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
required List<String>? additional, required List<String>? additional,
}) = _CanceledModerationModel; }) = _CanceledModerationModel;
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json); factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
_$CanceledModerationModelFromJson(json);
} }

View File

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

View File

@@ -1,42 +1,118 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; 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/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/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/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/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> { class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final ConsultantRepository consultantRepository; final ConsultantRepository consultantRepository;
final Duration scoringPollInterval;
bool _update = false; bool _update = false;
String? _userSlug;
Timer? _scoringPollTimer;
RegisterConsultantCubit({required this.consultantRepository}) static const String individualPersonLegalType = 'individualPerson';
: super(
const RegisterConsultantState( RegisterConsultantCubit({
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), required this.consultantRepository,
loading: true, this.scoringPollInterval = const Duration(seconds: 3),
acceptOffer: false, }) : super(
isUpdating: false, 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 { Future<void> getMyRequestStatus(String slug) async {
try { try {
_update = true; _update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); _userSlug = slug;
final myRequestStatus = await consultantRepository.getMyRequestStatus(
slug,
);
final certPhotos = await consultantRepository.getConsultantCertPhotos(); final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { List<CertificatesModel>? certificates = myRequestStatus
final course = courseMap.values.first; .educationModel
.completedCourses
?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(title: course.title, date: course.date); return CertificatesModel(title: course.title, date: course.date);
}).toList(); })
.toList();
String? sanitizeLink(String? link) { String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null; return (link != null && link.isNotEmpty) ? link : null;
} }
final legal = myRequestStatus.legalEntity;
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
emit( emit(
RegisterConsultantState( RegisterConsultantState(
consultant: ConsultantProfileModel( consultant: ConsultantProfileModel(
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), 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, acceptOffer: false,
acceptFrdoAgreement: false,
loading: false, loading: false,
requestModel: myRequestStatus, requestModel: myRequestStatus,
certPhotos: certPhotos, certPhotos: certPhotos,
sbpBanks: sbpBanks,
isUpdating: _update, isUpdating: _update,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
fieldErrors: const <String, String>{},
error: null, 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) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(loading: false, error: e.toString())); final parsed = parseLaravelValidationErrors(e);
} emit(
} state.copyWith(
loading: false,
Future<void> disableLoading() async { error: parsed.summary,
try { fieldErrors: parsed.fieldErrors,
final certPhotos = await consultantRepository.getConsultantCertPhotos(); awaitingScoringOutcome: false,
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()));
} }
} }
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
if (slug != null) { if (slug != null) {
await getMyRequestStatus(slug); await getMyRequestStatus(slug);
} else { } else {
await disableLoading(); await disableLoading(slug: _userSlug);
} }
} }
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { void updateConsultantProfileModel(
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); ConsultantProfileModel consultant, [
bool? acceptOffer,
bool? acceptFrdoAgreement,
]) {
final nextFieldErrors = state.fieldErrors.isEmpty
? const <String, String>{}
: PassportDataValidator.validate(consultant);
emit(
state.copyWith(
consultant: consultant,
acceptOffer: acceptOffer ?? state.acceptOffer,
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
loading: false,
error: null,
fieldErrors: nextFieldErrors,
),
);
}
Map<String, String> validatePassportFields() {
final errors = PassportDataValidator.validate(state.consultant);
emit(state.copyWith(fieldErrors: errors, error: null));
return errors;
}
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 { Future<void> registerConsultant() async {
emit(state.copyWith(error: null)); final passportErrors = validatePassportFields();
if (passportErrors.isNotEmpty) {
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 { try {
if (state.newAddedPhotos.isNotEmpty) { if (state.newAddedPhotos.isNotEmpty) {
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); await consultantRepository.storeConsultantCertPhotos(
files: state.newAddedPhotos,
);
} }
if (state.deletedPhotoIds.isNotEmpty) { if (state.deletedPhotoIds.isNotEmpty) {
for (final int id in state.deletedPhotoIds) { for (final int id in state.deletedPhotoIds) {
await consultantRepository.deletePhoto(id); 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) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); 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;
}
final passportErrors = validatePassportFields();
if (passportErrors.isNotEmpty) {
return;
}
emit(
state.copyWith(
scoringRetryInProgress: true,
error: null,
fieldErrors: const <String, String>{},
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
awaitingScoringOutcome: true,
),
);
_update = true;
try {
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 { Future<void> addCertPhotos(List<File> files) async {
if (files.isEmpty) { if (files.isEmpty) {
return; return;
} }
try { 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) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
return; return;
} }
if (path != null) { if (path != null) {
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); emit(
state.copyWith(
newAddedPhotos: state.newAddedPhotos
.where((file) => file.uri.path != path)
.toList(),
),
);
} }
} }
@override
Future<void> close() {
_stopScoringPoll();
return super.close();
}
} }

View File

@@ -1,8 +1,10 @@
import 'dart:io'; import '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_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/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/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'register_consultant_state.freezed.dart'; part 'register_consultant_state.freezed.dart';
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
const factory RegisterConsultantState({ const factory RegisterConsultantState({
required ConsultantProfileModel consultant, required ConsultantProfileModel consultant,
required bool acceptOffer, required bool acceptOffer,
@Default(false) bool acceptFrdoAgreement,
required bool loading, required bool loading,
required bool isUpdating, required bool isUpdating,
ConsultationRequestModel? requestModel, ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos, @Default(<ConsultantCertPhotoModel>[])
List<ConsultantCertPhotoModel> certPhotos,
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
@Default(<File>[]) List<File> newAddedPhotos, @Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds, @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, String? error,
}) = _RegisterConsultantState; }) = _RegisterConsultantState;
} }

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/locator_service.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/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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
} }
class _LastStepScreenState extends State<LastStepScreen> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cubit = context.watch<RegisterConsultantCubit>();
final state = cubit.state;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 80, top: 16), padding: const EdgeInsets.only(bottom: 80, top: 16),
child: Column( child: Column(
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), 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), const SizedBox(height: 16),
CheckboxListTile( CheckboxListTile(
value: checkboxState, value: state.acceptOffer,
title: Text( title: Text(
hidePlans hidePlans
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
: 'Я ознакомлен и принимаю договор публичной офёрты', : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
style: const TextStyle(fontSize: 18, height: 1), style: const TextStyle(fontSize: 18, height: 1.2),
), ),
onChanged: (_) { onChanged: (value) {
setState(() { cubit.updateAgreements(acceptOffer: value ?? false);
checkboxState = !checkboxState;
});
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
}, },
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
contentPadding: const EdgeInsets.symmetric(horizontal: 16), 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(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton( 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( child: Text(
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', hidePlans
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ? 'Политика конфиденциальности и пользовательское соглашение'
: 'Договор оферты',
style: const TextStyle(
height: 1,
fontSize: 16,
color: Color(0xFF323F4B),
decoration: TextDecoration.underline,
),
), ),
), ),
), ),
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
return Container( return Container(
height: 0.4, height: 0.4,
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
); );
} }
} }

View File

@@ -0,0 +1,599 @@
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/services.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,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
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,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
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,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
hasError: errors.containsKey('passport_number'),
onChanged: (_) => _syncToCubit(),
),
),
),
],
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['passport_issue_date'],
child: CustomTextFormField(
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
controller: issueDateController,
keyboardType: TextInputType.number,
inputFormatters: [dateMaskIssue],
hasError: errors.containsKey('passport_issue_date'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['passport_issuer_code'],
child: CustomTextFormField(
hintText: 'Код подразделения',
controller: issuerCodeController,
keyboardType: TextInputType.number,
inputFormatters: [issuerCodeMask],
hasError: errors.containsKey('passport_issuer_code'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['passport_issuer'],
child: CustomTextFormField(
hintText: 'Кем выдан',
controller: issuerController,
hasError: errors.containsKey('passport_issuer'),
onChanged: (_) => _syncToCubit(),
),
),
const PassportSectionTitle(title: 'Реквизиты СБП'),
PassportFieldWithError(
error: errors['sbp_phone'],
child: CustomTextFormField(
hintText: 'Телефон для выплат через СБП',
controller: sbpPhoneController,
keyboardType: TextInputType.phone,
inputFormatters: [phoneMask],
hasError: errors.containsKey('sbp_phone'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['sbp_bank_member_id'],
child: TypeAheadField<SbpBankModel>(
debounceDuration: const Duration(milliseconds: 250),
controller: sbpBankController,
suggestionsController: sbpBankSuggestionsController,
builder: (context, controller, focusNode) =>
CustomTextFormField(
controller: controller,
focusNode: focusNode,
hintText: 'Банк получатель (поиск)',
hasError: errors.containsKey('sbp_bank_member_id'),
onChanged: _clearSbpBankIfNeeded,
),
suggestionsCallback: (query) async {
return context
.read<RegisterConsultantCubit>()
.searchSbpBanks(query);
},
itemBuilder: (context, bank) {
return ListTile(
dense: true,
title: Text(
bank.bankName,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black900,
),
),
subtitle:
bank.shortName == null || bank.shortName!.isEmpty
? null
: Text(
bank.shortName!,
style: TextStyles.textSMedium.copyWith(
color: appTheme.grey7B,
),
),
);
},
decorationBuilder: (context, child) => Material(
type: MaterialType.card,
elevation: 4,
borderRadius: BorderRadius.circular(8),
color: appTheme.whiteA700,
child: child,
),
emptyBuilder: (context) => Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Ничего не найдено',
style: TextStyles.textSMedium.copyWith(
color: appTheme.grey7B,
),
),
),
onSelected: _selectSbpBank,
),
),
const SizedBox(height: 16),
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
const SizedBox(height: 16),
Text(
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
style: TextStyles.textSMedium.copyWith(
color: appTheme.blueA400,
),
),
],
),
);
},
),
);
}
}

View File

@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>( BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) { 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 addedPhotos = state.newAddedPhotos;
final totalPhotosCount = photos.length + addedPhotos.length; final totalPhotosCount = photos.length + addedPhotos.length;
return Column( return Column(
children: [ children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], if (isUploading) ...[
const LinearProgressIndicator(),
const SizedBox(height: 16),
],
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
crossAxisCount: 3, const SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 3 / 4, crossAxisCount: 3,
crossAxisSpacing: 8, childAspectRatio: 3 / 4,
mainAxisSpacing: 8, crossAxisSpacing: 8,
), mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
if (index == totalPhotosCount) { if (index == totalPhotosCount) {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(), onTap: () => _pickAndUploadPhotos(),
child: Ink( 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), child: Icon(Icons.add, color: appTheme.blueA400),
), ),
); );
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
return _LocalCertPhotoCard( return _LocalCertPhotoCard(
photo: localPhoto, photo: localPhoto,
allPhotos: addedPhotos, allPhotos: addedPhotos,
onRemove: () => _removePhoto(path: localPhoto.uri.path), onRemove: () =>
_removePhoto(path: localPhoto.uri.path),
); );
} }
} }
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
}); });
try { try {
// ignore: use_build_context_synchronously // 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 { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
final List<ConsultantCertPhotoModel> allPhotos; final List<ConsultantCertPhotoModel> allPhotos;
final VoidCallback onRemove; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
onTap: () { onTap: () {
context.pushTransparentRoute( context.pushTransparentRoute(
rootNavigator: true, 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), borderRadius: BorderRadius.circular(20),
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
Ink( Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)), 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( Positioned(
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), decoration: const BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), 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 List<File> allPhotos;
final VoidCallback onRemove; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), decoration: const BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
), ),
), ),
), ),

View File

@@ -1,5 +1,7 @@
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RegistrationAsScreen extends StatefulWidget { class RegistrationAsScreen extends StatefulWidget {
const RegistrationAsScreen({super.key}); const RegistrationAsScreen({super.key});
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
enum RegistrationAsType { physical, selfBusy, iP, ooo } enum RegistrationAsType { physical, selfBusy, iP, ooo }
class _RegistrationAsScreenState extends State<RegistrationAsScreen> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), 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), const SizedBox(height: 16),
Column( Column(
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: (_) {},
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), 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, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: null,
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), 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, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: null,
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), 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, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: null,
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), 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( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), child: Text(
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
),
), ),
], ],
), ),
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
return Container( return Container(
height: 0.4, height: 0.4,
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
); );
} }
} }

View File

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

View File

@@ -1,11 +1,13 @@
import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.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_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/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/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/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/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/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/registration_as_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
class StepperBecomeConsultantScreen extends StatefulWidget { class StepperBecomeConsultantScreen extends StatefulWidget {
final String? slug; final String? slug;
final bool? isCanceled; 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 @override
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState(); State<StepperBecomeConsultantScreen> createState() =>
_StepperBecomeConsultantScreenState();
} }
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> { class _StepperBecomeConsultantScreenState
double progressValue = 1 / 6; extends State<StepperBecomeConsultantScreen> {
double progressValue = 0;
int step = 1; int step = 1;
final pageController = PageController(); final pageController = PageController();
late final RegisterConsultantCubit registrationConsultantCubit; late final RegisterConsultantCubit registrationConsultantCubit;
bool isLoading = false; bool isLoading = false;
bool _successSheetShown = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.slug == null) { if (widget.isNewRegistration || widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading(); registrationConsultantCubit = sl<RegisterConsultantCubit>()
..disableLoading(slug: widget.slug);
} else { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<RegisterConsultantCubit>( return BlocProvider<RegisterConsultantCubit>(
create: (context) => registrationConsultantCubit, create: (context) => registrationConsultantCubit,
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>( child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) { listenWhen: (previous, current) =>
if (state.loading) { previous.fieldErrors.isEmpty &&
return const Scaffold(body: Loader()); current.fieldErrors.isNotEmpty &&
} current.error == null,
if (state.error != null) { listener: (context, state) {
pageController.jumpToPage(5);
},
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 &&
state.requestModel == null &&
widget.slug != null &&
state.alfaScoringStatus == AlfaScoringStatus.none) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
leading: BackButton(
onPressed: () {
if (context.canPop()) {
GoRouter.of(context).pop();
return;
}
GoRouter.of(context).go('/feed-screen');
},
),
title: Text(
'',
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
),
),
body: DataErrorWidget(
onReload: () => context
.read<RegisterConsultantCubit>()
.reloadData(widget.slug),
),
);
}
if (progressValue == 0) {
progressValue = 1 / totalSteps;
}
final submitDisabled =
isLoading ||
state.isScoringPolling ||
state.scoringRetryInProgress;
return Scaffold( return Scaffold(
appBar: AppBar( floatingActionButtonLocation:
centerTitle: true, FloatingActionButtonLocation.centerFloat,
leading: BackButton(
onPressed: () {
if (context.canPop()) {
GoRouter.of(context).pop();
return;
}
GoRouter.of(context).go('/feed-screen');
},
),
title: Text(
'',
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
body: DataErrorWidget(
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
),
);
}
return Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row( floatingActionButton: Row(
children: [ children: [
Container( Container(
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0, highlightElevation: 0,
focusElevation: 0, focusElevation: 0,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: const Color(0xFFEAF2FD), backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async { onPressed: () async {
setState(() { setState(() {
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}); });
}, },
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400), label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
), ),
), ),
if (widget.isCanceled == true) if (widget.isCanceled == true && step != totalSteps)
InkWell( InkWell(
onTap: () { onTap: () {
final canceledModeration =
state.requestModel?.canceledModeration;
if (canceledModeration == null) return;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(state.requestModel!.canceledModeration!.title), title: Text(canceledModeration.title),
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ if (canceledModeration.additional != null)
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), for (final item
], in canceledModeration.additional!) ...[
Text('• $item'),
],
], ],
), ),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
), ),
); );
}, },
@@ -127,18 +276,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: Ink( child: Ink(
height: 48, height: 48,
width: MediaQuery.of(context).size.width * 0.6, width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
child: const Center( child: const Center(
child: Text( child: Text(
'Комментарий', 'Комментарий',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
), ),
), ),
), ),
), ),
Container( Container(
height: 48, height: 48,
width: widget.isCanceled == true width: widget.isCanceled == true && step != totalSteps
? MediaQuery.of(context).size.width * 0.2 ? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8, : MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16), padding: const EdgeInsets.only(left: 4, right: 16),
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0, highlightElevation: 0,
elevation: 0, elevation: 0,
focusElevation: 0, focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
backgroundColor: isLoading borderRadius: BorderRadius.circular(20),
),
backgroundColor: submitDisabled
? Colors.grey ? Colors.grey
: widget.isCanceled == true : widget.isCanceled == true && step != totalSteps
? const Color(0xFFEAF2FD) ? const Color(0xFFEAF2FD)
: appTheme.blueA400, : appTheme.blueA400,
onPressed: isLoading onPressed: submitDisabled
? null ? null
: () async { : () async {
if (step == 6) { if (step == totalSteps) {
if (widget.isCanceled == true) { final passportErrors =
registrationConsultantCubit
.validatePassportFields();
if (passportErrors.isNotEmpty) {
pageController.jumpToPage(5);
return; return;
} }
if (registrationConsultantCubit.state.acceptOffer) { if (registrationConsultantCubit
.hasAcceptedRequiredAgreements) {
setState(() { setState(() {
isLoading = true; isLoading = true;
}); });
try { try {
await registrationConsultantCubit.registerConsultant(); await registrationConsultantCubit
.registerConsultant();
if (!context.mounted) return; if (!context.mounted) return;
if (registrationConsultantCubit.state.error != null) { if (registrationConsultantCubit
return; .state
} .error !=
await showModalBottomSheet( null) {
useRootNavigator: true, showDialog(
showDragHandle: true, context: context,
context: context, builder: (context) => AlertDialog(
builder: (context) => Container( actionsPadding:
padding: const EdgeInsets.all(16), const EdgeInsets.symmetric(
width: MediaQuery.of(context).size.width, horizontal: 4,
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),
),
),
), ),
content: Text(
registrationConsultantCubit
.state
.error!,
),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
), );
); return;
}
// Success sheet is shown by BlocListener when scoring completes.
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(e.toString()), content: Text(e.toString()),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Ок'), child: const Text('Ок'),
onPressed: () => Navigator.of(context).pop(), onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding:
content: const Text('Примите договор публичной офёрты'), const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(
state.isUpdating
? 'Примите договор публичной оферты'
: 'Примите оферту и согласие ФРДО',
),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Ок'), child: const Text('Ок'),
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
} else { } else {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
setState(() { setState(() {
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}); });
} }
}, },
label: isLoading label: submitDisabled && step == totalSteps
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: widget.isCanceled == true : widget.isCanceled == true && step != totalSteps
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) ? Icon(
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text(
step == totalSteps ? 'Отправить' : 'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(
color: Colors.white,
),
),
), ),
), ),
], ],
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
leading: Builder( leading: Builder(
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), builder: (context) => IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => GoRouter.of(context).pop(),
),
), ),
centerTitle: true, centerTitle: true,
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), title: SvgPicture.asset(
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], ImageConstant.imgLogoEatvio,
height: 48,
fit: BoxFit.contain,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('шаг $step из $totalSteps'),
),
],
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, 32), preferredSize: Size(MediaQuery.of(context).size.width, 32),
child: Padding( child: Padding(
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 350), duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut, curve: Curves.easeInOut,
tween: Tween<double>(begin: 1 / 6, end: progressValue), tween: Tween<double>(
begin: 1 / totalSteps,
end: progressValue,
),
builder: (context, value, _) => LinearProgressIndicator( builder: (context, value, _) => LinearProgressIndicator(
value: value, value: value,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
minHeight: 8, minHeight: 8,
backgroundColor: Colors.grey.shade100, backgroundColor: Colors.grey.shade100,
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400), valueColor: AlwaysStoppedAnimation<Color>(
appTheme.blueA400,
),
), ),
), ),
), ),
@@ -301,20 +485,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
onPageChanged: (index) { onPageChanged: (index) {
setState(() { setState(() {
step = index + 1; step = index + 1;
progressValue = step / 6; progressValue = step / totalSteps;
}); });
}, },
children: const [ children: _pages(),
CertificatesAndDiplomasScreen(),
PhotoOrScansScreen(),
AdditionalSettingsScreen(),
SocialNetworksScreen(),
RegistrationAsScreen(),
LastStepScreen(),
],
), ),
); );
}, },
),
), ),
); );
} }

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_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/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/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:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
@@ -11,36 +12,53 @@ class ConsultantRepository {
const ConsultantRepository({required this.consultantService}); 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) { if (update) {
return await consultantService.consultantUpdate(consultantModel: consultant); return await consultantService.consultantUpdate(
consultantModel: consultant,
);
} else { } 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); return await consultantService.getConsultantRequestStatus(slug: slug);
} }
/// Своя заявка — education + legal/passport для регистрации и update.
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async { Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
return await consultantService.getMyRequestStatus(slug: slug); return await consultantService.getMyRequestStatus(slug: slug);
}
Future<List<SbpBankModel>> getSbpBanks() async {
return await consultantService.getSbpBanks();
} }
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async { Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
return await consultantService.getConsultantCertPhotos(); 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>[]; final multipartFiles = <MultipartFile>[];
for (final file in files) { for (final file in files) {
final fileBytes = await file.readAsBytes(); 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); multipartFiles.add(multipartFile);
} }
@@ -48,22 +66,24 @@ class ConsultantRepository {
return []; return [];
} }
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); return await consultantService.storeConsultantCertPhotos(
photos: multipartFiles,
);
} }
Future<void> hideConsultantCertPhoto(int id) async { 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 { 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 { Future<void> deletePhoto(int id) async {
await consultantService.deletePhoto(id: id); await consultantService.deletePhoto(id: id);
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_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/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/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:retrofit/retrofit.dart'; import 'package:retrofit/retrofit.dart';
part 'consultant_service.g.dart'; part 'consultant_service.g.dart';
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
abstract class ConsultantService { abstract class ConsultantService {
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
@POST('/api/consultant_request/registration') @POST('/api/v2/consultant_request/registration')
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); Future<void> consultantRegistration({
@Body() required ConsultantProfileModel consultantModel,
});
@POST('/api/consultant_request/photos/upload') @POST('/api/consultant_request/photos/upload')
@MultiPart() @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') @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') @PUT('/api/v2/consultant_request/update')
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); Future<void> consultantUpdate({
@Body() required ConsultantProfileModel consultantModel,
});
@GET('/api/finances/sbp_banks')
Future<List<SbpBankModel>> getSbpBanks();
@GET('/api/v2/consultant_request/cert_photos') @GET('/api/v2/consultant_request/cert_photos')
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos(); Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
@@ -32,14 +51,19 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/store') @POST('/api/v2/consultant_request/cert_photos/store')
@MultiPart() @MultiPart()
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({ 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') @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') @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') @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id}); Future<void> deletePhoto({@Path() required int id});

View File

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

View File

@@ -0,0 +1,185 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
/// Client-side validation for individual-person passport fields.
///
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
/// look the same in the UI. Dates also reject non-calendar values and
/// dates after today.
abstract final class PassportDataValidator {
static const futureDateMessage = 'Дата не может быть в будущем';
static Map<String, String> validate(
ConsultantProfileModel consultant, {
DateTime? now,
}) {
final today = now ?? DateTime.now();
final errors = <String, String>{};
_requireText(
errors,
key: 'last_name',
value: consultant.lastName,
emptyMessage: 'Укажите фамилию',
);
_requireText(
errors,
key: 'first_name',
value: consultant.firstName,
emptyMessage: 'Укажите имя',
);
_requirePattern(
errors,
key: 'inn',
value: consultant.inn,
pattern: RegExp(r'^\d{12}$'),
emptyMessage: 'Укажите ИНН',
invalidMessage: 'Некорректный ИНН',
);
_requirePattern(
errors,
key: 'snils',
value: consultant.snils,
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
emptyMessage: 'Укажите СНИЛС',
invalidMessage: 'Некорректный СНИЛС',
);
_requirePattern(
errors,
key: 'passport_series',
value: consultant.passportSeries,
pattern: RegExp(r'^\d{4}$'),
emptyMessage: 'Укажите серию паспорта',
invalidMessage: 'Некорректная серия паспорта',
);
_requirePattern(
errors,
key: 'passport_number',
value: consultant.passportNumber,
pattern: RegExp(r'^\d{6}$'),
emptyMessage: 'Укажите номер паспорта',
invalidMessage: 'Некорректный номер паспорта',
);
_requireText(
errors,
key: 'passport_issuer',
value: consultant.passportIssuer,
emptyMessage: 'Укажите, кем выдан паспорт',
);
_requirePattern(
errors,
key: 'passport_issuer_code',
value: consultant.passportIssuerCode,
pattern: RegExp(r'^\d{3}-\d{3}$'),
emptyMessage: 'Укажите код подразделения',
invalidMessage: 'Некорректный код подразделения',
);
_requireDate(
errors,
key: 'passport_issue_date',
value: consultant.passportIssueDate,
emptyMessage: 'Укажите дату выдачи паспорта',
invalidMessage: 'Некорректная дата выдачи',
today: today,
);
_requireDate(
errors,
key: 'birth_date',
value: consultant.birthDate,
emptyMessage: 'Укажите дату рождения',
invalidMessage: 'Некорректная дата рождения',
today: today,
);
_requireText(
errors,
key: 'birth_place',
value: consultant.birthPlace,
emptyMessage: 'Укажите место рождения',
);
_requireText(
errors,
key: 'registration_address',
value: consultant.registrationAddress,
emptyMessage: 'Укажите адрес регистрации',
);
_requireText(
errors,
key: 'residence_address',
value: consultant.residenceAddress,
emptyMessage: 'Укажите адрес проживания',
);
_requirePattern(
errors,
key: 'citizenship',
value: consultant.citizenship,
pattern: RegExp(r'^[A-Za-z]{2}$'),
emptyMessage: 'Укажите гражданство',
invalidMessage: 'Некорректный код гражданства',
);
return errors;
}
static void _requireText(
Map<String, String> errors, {
required String key,
required String? value,
required String emptyMessage,
}) {
if (value == null || value.trim().isEmpty) {
errors[key] = emptyMessage;
}
}
static void _requirePattern(
Map<String, String> errors, {
required String key,
required String? value,
required RegExp pattern,
required String emptyMessage,
required String invalidMessage,
}) {
final raw = value?.trim() ?? '';
if (raw.isEmpty) {
errors[key] = emptyMessage;
return;
}
if (!pattern.hasMatch(raw)) {
errors[key] = invalidMessage;
}
}
static void _requireDate(
Map<String, String> errors, {
required String key,
required String? value,
required String emptyMessage,
required String invalidMessage,
required DateTime today,
}) {
final raw = value?.trim() ?? '';
if (raw.isEmpty) {
errors[key] = emptyMessage;
return;
}
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
if (match == null) {
errors[key] = invalidMessage;
return;
}
final year = int.parse(match.group(1)!);
final month = int.parse(match.group(2)!);
final day = int.parse(match.group(3)!);
final parsed = DateTime(year, month, day);
if (parsed.year != year || parsed.month != month || parsed.day != day) {
errors[key] = invalidMessage;
return;
}
final todayDate = DateTime(today.year, today.month, today.day);
if (parsed.isAfter(todayDate)) {
errors[key] = futureDateMessage;
}
}
}

View File

@@ -18,6 +18,7 @@ class BlogPostMediaContent extends StatefulWidget {
required this.onOpenGallery, required this.onOpenGallery,
required this.onGalleryChanged, required this.onGalleryChanged,
required this.videoUrlThumb, required this.videoUrlThumb,
this.heroTag,
}); });
final List<FeedGalleryImage> galleryImages; final List<FeedGalleryImage> galleryImages;
@@ -28,6 +29,9 @@ class BlogPostMediaContent extends StatefulWidget {
final void Function(List<String> urls, int index) onOpenGallery; final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> onGalleryChanged; final ValueChanged<int> onGalleryChanged;
/// Unique per post. Required when several cards with the same [imageUrl] share a route.
final Object? heroTag;
@override @override
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState(); State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
} }
@@ -44,12 +48,14 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
bool _isCounterVisible = false; bool _isCounterVisible = false;
Timer? _counterHideTimer; Timer? _counterHideTimer;
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; bool get _hasVideo =>
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
List<FeedGalleryImage> get _gallery { List<FeedGalleryImage> get _gallery {
return [ return [
for (final image in widget.galleryImages) for (final image in widget.galleryImages)
if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty) if (PostFeedMapper.resolveMediaUrl(image.url) case final url
when url.isNotEmpty)
FeedGalleryImage( FeedGalleryImage(
url: url, url: url,
orientation: image.orientation, orientation: image.orientation,
@@ -71,7 +77,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return 0; return 0;
} }
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length; bool get _isCurrentSlideVideo =>
_hasVideo && widget.currentGalleryIndex == _gallery.length;
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
@@ -84,7 +91,9 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
} }
double _heightForOrientation(String orientation, double width) { double _heightForOrientation(String orientation, double width) {
final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio; final aspectRatio = orientation == 'vertical'
? _verticalAspectRatio
: _horizontalAspectRatio;
final maxHeight = MediaQuery.sizeOf(context).height * 0.7; final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
return (width / aspectRatio).clamp(180.0, maxHeight); return (width / aspectRatio).clamp(180.0, maxHeight);
} }
@@ -115,12 +124,15 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
} }
bool _handleCarouselScroll(ScrollNotification notification) { bool _handleCarouselScroll(ScrollNotification notification) {
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { if (notification is ScrollStartNotification ||
notification is ScrollUpdateNotification) {
_counterHideTimer?.cancel(); _counterHideTimer?.cancel();
if (!_isCounterVisible) { if (!_isCounterVisible) {
setState(() => _isCounterVisible = true); setState(() => _isCounterVisible = true);
} }
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { } else if (notification is ScrollEndNotification &&
_isCounterVisible &&
_isCurrentSlideVideo) {
_scheduleCounterHideOnVideo(); _scheduleCounterHideOnVideo();
} }
return false; return false;
@@ -140,17 +152,25 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
} }
Widget _videoSlide() { Widget _videoSlide() {
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); return FeedVideoPlayer(
embedUrl: widget.videoEmbedUrl!,
videoUrlThumb: widget.videoUrlThumb,
);
} }
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) { Widget _carousel({
required int itemCount,
required NullableIndexedWidgetBuilder itemBuilder,
required double height,
}) {
return Stack( return Stack(
children: [ children: [
NotificationListener<ScrollNotification>( NotificationListener<ScrollNotification>(
onNotification: _handleCarouselScroll, onNotification: _handleCarouselScroll,
child: CarouselSlider.builder( child: CarouselSlider.builder(
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), itemBuilder: (context, itemIndex, _) =>
itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
options: CarouselOptions( options: CarouselOptions(
viewportFraction: 1, viewportFraction: 1,
height: height, height: height,
@@ -167,7 +187,10 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: _shouldShowCounter ? 1 : 0, opacity: _shouldShowCounter ? 1 : 0,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), child: _GalleryPhotoCounter(
currentIndex: widget.currentGalleryIndex,
total: itemCount,
),
), ),
), ),
), ),
@@ -203,25 +226,21 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
} }
if (_hasVideo) { if (_hasVideo) {
return SizedBox( return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
height: mediaHeight,
width: width,
child: _videoSlide(),
);
} }
if (widget.imageUrl.isNotEmpty) { if (widget.imageUrl.isNotEmpty) {
final image = CachedNetworkImage(
fit: BoxFit.fitWidth,
width: width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
);
return GestureDetector( return GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0), onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: Hero( child: widget.heroTag == null
tag: widget.imageUrl, ? image
child: CachedNetworkImage( : Hero(tag: widget.heroTag!, child: image),
fit: BoxFit.fitWidth,
width: width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
); );
} }
@@ -239,15 +258,27 @@ class _GalleryPhotoCounter extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(999),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), const Icon(
Icons.photo_library_outlined,
size: 14,
color: Colors.white,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'${currentIndex + 1} / $total', '${currentIndex + 1} / $total',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.2,
),
), ),
], ],
), ),

View File

@@ -51,14 +51,18 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
totalLikes = post.totalLikes; totalLikes = post.totalLikes;
return; return;
} }
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) { if (oldWidget.post.wasLikedByMe != post.wasLikedByMe ||
oldWidget.post.totalLikes != post.totalLikes) {
isLiked = post.wasLikedByMe; isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes; totalLikes = post.totalLikes;
} }
} }
void _openGallery(List<String> imageUrls, int index) { void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
);
} }
Future<void> _toggleLike() async { Future<void> _toggleLike() async {
@@ -82,7 +86,8 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), onTap: () =>
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
child: Material( child: Material(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: Colors.white, color: Colors.white,
@@ -96,10 +101,19 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
BlogPostMediaContent( BlogPostMediaContent(
galleryImages: post.images, galleryImages: post.images,
imageUrl: imageUrl, imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb, videoUrlThumb: post.videoUrlThumb,
currentGalleryIndex: currentGalleryIndex, currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery, heroTag: 'feed-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'feed-blog-post-${post.id}-cover'
: null,
),
onGalleryChanged: (index) { onGalleryChanged: (index) {
setState(() { setState(() {
currentGalleryIndex = index; currentGalleryIndex = index;
@@ -107,7 +121,12 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike), _BlogPostActionsRow(
post: post,
isLiked: isLiked,
totalLikes: totalLikes,
onToggleLike: _toggleLike,
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -129,11 +148,19 @@ class _BlogPostHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug), onTap: () => GoRouter.of(
context,
).pushNamed('profileScreen', extra: post.author.authorSlug),
child: CircleAvatar( child: CircleAvatar(
radius: 20, radius: 20,
backgroundColor: Colors.black12, backgroundColor: Colors.black12,
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null, foregroundImage: post.author.authorProfileIcon.isNotEmpty
? NetworkImage(
PostFeedMapper.resolveMediaUrl(
post.author.authorProfileIcon,
),
)
: null,
backgroundImage: const AssetImage('assets/images/no_avatar.png'), backgroundImage: const AssetImage('assets/images/no_avatar.png'),
), ),
), ),
@@ -145,17 +172,27 @@ class _BlogPostHeader extends StatelessWidget {
Text( Text(
post.author.authorName, post.author.authorName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), style: const TextStyle(
height: 1,
color: Colors.black87,
fontWeight: FontWeight.w500,
fontSize: 16,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', '${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), style: const TextStyle(
height: 1,
color: Colors.black45,
fontSize: 14,
),
), ),
], ],
), ),
), ),
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe) if (!(post.author.isEntitySubscribedByMe ?? false) &&
!post.author.authorIsMe)
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: SubscribeButtonWidget(slug: post.author.authorSlug), child: SubscribeButtonWidget(slug: post.author.authorSlug),
@@ -190,33 +227,51 @@ class _BlogPostTextContent extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text(
post.title, post.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
), ),
), ),
], ],
if (post.title.isEmpty) const SizedBox(height: 8), if (post.title.isEmpty) const SizedBox(height: 8),
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), if (post.title.isNotEmpty && post.description.isNotEmpty)
const SizedBox(height: 8),
if (post.description.isNotEmpty) if (post.description.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)), child: MarkdownHtmlWidget(
markdownText: _truncateDescription(post.description),
),
), ),
if (post.description.length > 200) if (post.description.length > 200)
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 12), padding: EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text(
'Показать ещё', 'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
), ),
), ),
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), if (post.title.isNotEmpty || post.description.isNotEmpty)
const SizedBox(height: 8),
], ],
); );
} }
} }
class _BlogPostActionsRow extends StatelessWidget { class _BlogPostActionsRow extends StatelessWidget {
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); const _BlogPostActionsRow({
required this.post,
required this.isLiked,
required this.totalLikes,
required this.onToggleLike,
});
final FeedBlogPostModel post; final FeedBlogPostModel post;
final bool isLiked; final bool isLiked;
@@ -230,7 +285,9 @@ class _BlogPostActionsRow extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
FeedIconButton( FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: totalLikes.toString(), text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
@@ -239,19 +296,34 @@ class _BlogPostActionsRow extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FeedIconButton( FeedIconButton(
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), svgIcon: SvgPicture.asset(
'assets/icons/comment.svg',
height: 20,
width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
text: post.totalComments.toString(), text: post.totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), onTap: () => GoRouter.of(
context,
).pushNamed('blogPostScreen', extra: post.id),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FeedIconButton( FeedIconButton(
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), svgIcon: SvgPicture.asset(
height: 15,
width: 12,
'assets/icons/repost.svg',
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
onTap: () { onTap: () {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share( SharePlus.instance.share(
ShareParams( ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'), uri: Uri.parse(
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, '${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) & box.size,
), ),
); );
}, },
@@ -261,7 +333,11 @@ class _BlogPostActionsRow extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
post.totalViews.toString(), post.totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
), ),
], ],
), ),

View File

@@ -116,9 +116,7 @@ class _PlanWidgetState extends State<PlanWidget> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: MarkdownHtmlWidget( child: MarkdownHtmlWidget(
markdownText: widget.plan.details.length > 200 markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
? '${widget.plan.details.substring(0, 200)}...'
: widget.plan.details,
textColor: appTheme.black, textColor: appTheme.black,
), ),
), ),

View File

@@ -0,0 +1,15 @@
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'promo_applied_list_response.freezed.dart';
part 'promo_applied_list_response.g.dart';
@freezed
abstract class PromoAppliedListResponse with _$PromoAppliedListResponse {
const factory PromoAppliedListResponse({
@JsonKey(name: 'promo_codes') required List<PromoCodeModel> promoCodes,
}) = _PromoAppliedListResponse;
factory PromoAppliedListResponse.fromJson(Map<String, dynamic> json) =>
_$PromoAppliedListResponseFromJson(json);
}

View File

@@ -0,0 +1,21 @@
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'promo_apply_response.freezed.dart';
part 'promo_apply_response.g.dart';
@freezed
abstract class PromoApplyResponse with _$PromoApplyResponse {
const factory PromoApplyResponse({
required bool success,
String? error,
@JsonKey(name: 'promo_code') PromoCodeModel? promoCode,
@JsonKey(name: 'discount_percent') int? discountPercent,
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
}) = _PromoApplyResponse;
factory PromoApplyResponse.fromJson(Map<String, dynamic> json) =>
_$PromoApplyResponseFromJson(json);
}

View File

@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'promo_code_model.freezed.dart';
part 'promo_code_model.g.dart';
@freezed
abstract class PromoCodeModel with _$PromoCodeModel {
const factory PromoCodeModel({
required int id,
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
@JsonKey(name: 'promo_code') required String promoCode,
@JsonKey(name: 'discount_percent') required int discountPercent,
@JsonKey(name: 'discount_amount_rub') required double discountAmountRub,
required String state,
@JsonKey(name: 'applied_at') String? appliedAt,
@JsonKey(name: 'confirmed_at') String? confirmedAt,
@JsonKey(name: 'cancelled_at') String? cancelledAt,
}) = _PromoCodeModel;
factory PromoCodeModel.fromJson(Map<String, dynamic> json) =>
_$PromoCodeModelFromJson(json);
}

View File

@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'promo_remove_response.freezed.dart';
part 'promo_remove_response.g.dart';
@freezed
abstract class PromoRemoveResponse with _$PromoRemoveResponse {
const factory PromoRemoveResponse({
required bool success,
String? error,
}) = _PromoRemoveResponse;
factory PromoRemoveResponse.fromJson(Map<String, dynamic> json) =>
_$PromoRemoveResponseFromJson(json);
}

View File

@@ -0,0 +1,20 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'promo_validate_response.freezed.dart';
part 'promo_validate_response.g.dart';
@freezed
abstract class PromoValidateResponse with _$PromoValidateResponse {
const factory PromoValidateResponse({
required bool valid,
String? error,
@JsonKey(name: 'promo_code') String? promoCode,
@JsonKey(name: 'discount_percent') int? discountPercent,
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
}) = _PromoValidateResponse;
factory PromoValidateResponse.fromJson(Map<String, dynamic> json) =>
_$PromoValidateResponseFromJson(json);
}

View File

@@ -1,22 +1,73 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Оркестрирует флоу оплаты консультации: /// Оркестрирует флоу оплаты консультации:
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks. /// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks.
Future<void> showConsultationPaymentFlow( Future<void> showConsultationPaymentFlow(
BuildContext context, { BuildContext context, {
required int planId, required int planId,
Future<bool?> Function(BuildContext)? showGate, Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm, Future<PromoCodeResult?> Function(
BuildContext, {
Future<PromoValidation> Function(String)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
})? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp, Future<void> Function(BuildContext, {required int planId})? showSbp,
Future<PromoValidateResponse> Function({
required int planId,
required String promoCode,
})? validatePromo,
Future<PromoApplyResponse> Function({
required int planId,
required String promoCode,
})? applyPromo,
Future<PromoCodeModel?> Function(int planId)? listApplied,
Future<bool> Function(int planId)? removePromo,
}) async { }) async {
final gate = showGate ?? _showGate; final gate = showGate ?? _showGate;
final form = showForm ?? _showForm; final form = showForm ?? _showForm;
final sbp = showSbp ?? _showSbp; final sbp = showSbp ?? _showSbp;
final s = S.of(context); final validate = validatePromo ?? _defaultValidatePromo;
final apply = applyPromo ?? _defaultApplyPromo;
final findApplied = listApplied ?? _defaultFindApplied;
final remove = removePromo ?? _defaultRemovePromo;
final existing = await findApplied(planId);
if (!context.mounted) return;
if (existing != null) {
final formResult = await form(
context,
initialPromo: existing,
onRemove: () => remove(planId),
onValidate: (code) => _mapValidateResponse(
validate(planId: planId, promoCode: code),
),
);
if (formResult == null || !context.mounted) return;
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
final applied = await _tryApplyPromo(
apply: apply,
planId: planId,
promoCode: formResult.code!,
);
if (!applied || !context.mounted) return;
}
if (!context.mounted) return;
await sbp(context, planId: planId);
return;
}
final gateChoice = await gate(context); final gateChoice = await gate(context);
if (gateChoice == null || !context.mounted) return; if (gateChoice == null || !context.mounted) return;
@@ -24,9 +75,20 @@ Future<void> showConsultationPaymentFlow(
if (gateChoice) { if (gateChoice) {
final formResult = await form( final formResult = await form(
context, context,
onValidate: (_) async => PromoValidation.failure(s.promo_invalid), onValidate: (code) => _mapValidateResponse(
validate(planId: planId, promoCode: code),
),
); );
if (formResult == null || !context.mounted) return; if (formResult == null || !context.mounted) return;
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
final applied = await _tryApplyPromo(
apply: apply,
planId: planId,
promoCode: formResult.code!,
);
if (!applied || !context.mounted) return;
}
} }
if (!context.mounted) return; if (!context.mounted) return;
@@ -40,10 +102,96 @@ Future<bool?> _showGate(BuildContext context) {
Future<PromoCodeResult?> _showForm( Future<PromoCodeResult?> _showForm(
BuildContext context, { BuildContext context, {
Future<PromoValidation> Function(String)? onValidate, Future<PromoValidation> Function(String)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) { }) {
return ItvioPromoCodeModal.show(context, onValidate: onValidate); return ItvioPromoCodeModal.show(
context,
onValidate: onValidate,
onRemove: onRemove,
initialPromo: initialPromo,
);
} }
Future<void> _showSbp(BuildContext context, {required int planId}) { Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId); return showSbpBanksBottomSheet(context, planId: planId);
} }
Future<PromoValidateResponse> _defaultValidatePromo({
required int planId,
required String promoCode,
}) {
return sl<PaymentRepository>().validatePromoCode(
planId: planId,
promoCode: promoCode,
);
}
Future<PromoApplyResponse> _defaultApplyPromo({
required int planId,
required String promoCode,
}) {
return sl<PaymentRepository>().applyPromoCode(
planId: planId,
promoCode: promoCode,
);
}
Future<PromoCodeModel?> _defaultFindApplied(int planId) {
return sl<PaymentRepository>().findAppliedPromoForPlan(planId);
}
Future<bool> _defaultRemovePromo(int planId) async {
try {
final response = await sl<PaymentRepository>().removePromoCode(planId: planId);
if (response.success) return true;
showErrorToast(response.error ?? 'Не удалось убрать промокод.');
return false;
} catch (_) {
showErrorToast('Не удалось убрать промокод. Попробуйте позже');
return false;
}
}
Future<PromoValidation> _mapValidateResponse(
Future<PromoValidateResponse> request,
) async {
try {
final response = await request;
if (!response.valid) {
return PromoValidation.failure(
response.error ?? 'Промокод невалиден.',
);
}
return PromoValidation.success(
discountPercent: response.discountPercent,
discountAmountRub: response.discountAmountRub,
originalAmountRub: response.originalAmountRub,
finalAmountRub: response.finalAmountRub,
);
} catch (_) {
return const PromoValidation.failure(
'Не удалось проверить промокод. Попробуйте позже',
);
}
}
Future<bool> _tryApplyPromo({
required Future<PromoApplyResponse> Function({
required int planId,
required String promoCode,
}) apply,
required int planId,
required String promoCode,
}) async {
try {
final response = await apply(planId: planId, promoCode: promoCode);
if (response.success) return true;
showErrorToast(response.error ?? 'Не удалось применить промокод.');
return false;
} catch (_) {
showErrorToast('Не удалось применить промокод. Попробуйте позже');
return false;
}
}

View File

@@ -1,45 +1,87 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Результат работы модалки промокода. /// Результат работы модалки промокода.
class PromoCodeResult { class PromoCodeResult {
const PromoCodeResult({this.code}); const PromoCodeResult({
this.code,
this.discountPercent,
this.discountAmountRub,
this.originalAmountRub,
this.finalAmountRub,
this.alreadyApplied = false,
});
/// Применённый промокод или null, если пользователь продолжил без него. /// Применённый промокод или null, если пользователь продолжил без него.
final String? code; final String? code;
final int? discountPercent;
final double? discountAmountRub;
final double? originalAmountRub;
final double? finalAmountRub;
/// true — промокод уже на сервере (rehydrate Continue), apply не нужен.
final bool alreadyApplied;
bool get hasPromoCode => code != null; bool get hasPromoCode => code != null;
} }
/// Ответ проверки промокода. /// Ответ проверки промокода.
class PromoValidation { class PromoValidation {
const PromoValidation.success() : error = null; const PromoValidation.success({
const PromoValidation.failure(this.error); this.discountPercent,
this.discountAmountRub,
this.originalAmountRub,
this.finalAmountRub,
}) : error = null;
const PromoValidation.failure(this.error)
: discountPercent = null,
discountAmountRub = null,
originalAmountRub = null,
finalAmountRub = null;
final String? error; final String? error;
final int? discountPercent;
final double? discountAmountRub;
final double? originalAmountRub;
final double? finalAmountRub;
bool get isValid => error == null; bool get isValid => error == null;
} }
/// Модальное окно ввода промокода. /// Модальное окно ввода / просмотра применённого промокода.
class ItvioPromoCodeModal extends StatefulWidget { class ItvioPromoCodeModal extends StatefulWidget {
const ItvioPromoCodeModal({super.key, this.onValidate}); const ItvioPromoCodeModal({
super.key,
this.onValidate,
this.onRemove,
this.initialPromo,
});
final Future<PromoValidation> Function(String code)? onValidate; final Future<PromoValidation> Function(String code)? onValidate;
final Future<bool> Function()? onRemove;
final PromoCodeModel? initialPromo;
static Future<PromoCodeResult?> show( static Future<PromoCodeResult?> show(
BuildContext context, { BuildContext context, {
Future<PromoValidation> Function(String code)? onValidate, Future<PromoValidation> Function(String code)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) { }) {
return showModalBottomSheet<PromoCodeResult>( return showModalBottomSheet<PromoCodeResult>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true, useSafeArea: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate), builder: (_) => ItvioPromoCodeModal(
onValidate: onValidate,
onRemove: onRemove,
initialPromo: initialPromo,
),
); );
} }
@@ -47,14 +89,32 @@ class ItvioPromoCodeModal extends StatefulWidget {
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState(); State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
} }
enum _ModalState { input, loading } enum _ModalState { input, loading, applied }
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> { class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _focusNode = FocusNode(); final _focusNode = FocusNode();
_ModalState _state = _ModalState.input; late _ModalState _state;
String? _errorText; String? _errorText;
int? _discountPercent;
double? _discountAmountRub;
String? _appliedCode;
@override
void initState() {
super.initState();
final initial = widget.initialPromo;
if (initial != null) {
_state = _ModalState.applied;
_appliedCode = initial.promoCode;
_discountPercent = initial.discountPercent;
_discountAmountRub = initial.discountAmountRub;
_controller.text = initial.promoCode;
} else {
_state = _ModalState.input;
}
}
@override @override
void dispose() { void dispose() {
@@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
return; return;
} }
Navigator.of(context).pop(PromoCodeResult(code: code)); Navigator.of(context).pop(
PromoCodeResult(
code: code,
discountPercent: validation.discountPercent,
discountAmountRub: validation.discountAmountRub,
originalAmountRub: validation.originalAmountRub,
finalAmountRub: validation.finalAmountRub,
),
);
}
void _onContinueApplied() {
Navigator.of(context).pop(
PromoCodeResult(
code: _appliedCode,
discountPercent: _discountPercent,
discountAmountRub: _discountAmountRub,
alreadyApplied: true,
),
);
}
Future<void> _onRemove() async {
setState(() {
_state = _ModalState.loading;
_errorText = null;
});
var success = true;
try {
if (widget.onRemove != null) {
success = await widget.onRemove!();
}
} catch (_) {
success = false;
}
if (!mounted) return;
if (!success) {
setState(() {
_state = _ModalState.applied;
_errorText = S.of(context).promo_remove_failed;
});
return;
}
setState(() {
_state = _ModalState.input;
_appliedCode = null;
_discountPercent = null;
_discountAmountRub = null;
_errorText = null;
_controller.clear();
});
_focusNode.requestFocus();
} }
void _onSkip() { void _onSkip() {
@@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
bool get _isLoading => _state == _ModalState.loading; bool get _isLoading => _state == _ModalState.loading;
/// Applied UI while loading remove (state=loading, but promo still shown).
bool get _isApplied =>
_appliedCode != null && _state != _ModalState.input;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom; final bottomInset = MediaQuery.of(context).viewInsets.bottom;
@@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
children: [ children: [
const Center(child: BottomSheetHandlerBar()), const Center(child: BottomSheetHandlerBar()),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHeader(), _buildHeader(s),
if (_isApplied) ...[
const SizedBox(height: 6),
Text(
s.promo_applied_subtitle,
style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)),
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'Промокод', s.promo_code,
style: TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
color: Color(0xFF6B7684), color: Color(0xFF6B7684),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildField(), _buildField(s),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildHint(), _buildHint(s),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildApplyButton(s), if (_isApplied) ...[
const SizedBox(height: 6), _buildContinueButton(s),
_buildSkipButton(s), const SizedBox(height: 6),
_buildRemoveButton(s),
] else ...[
_buildApplyButton(s),
const SizedBox(height: 6),
_buildSkipButton(s),
],
], ],
), ),
), ),
); );
} }
Widget _buildHeader() { Widget _buildHeader(S s) {
final s = S.of(context);
return Text( return Text(
s.have_promo_code_form, _isApplied ? s.promo_applied_title : s.have_promo_code_form,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
); );
} }
Widget _buildField() { Widget _buildField(S s) {
final hasError = _errorText != null; final hasError = _errorText != null && !_isApplied;
final s = S.of(context);
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
@@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
width: 2, width: 2,
color: hasError color: hasError
? appTheme.red ? appTheme.red
: (_focusNode.hasFocus : (_focusNode.hasFocus && !_isApplied
? appTheme.blueA400 ? appTheme.blueA400
: Colors.transparent), : Colors.transparent),
), ),
@@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
child: TextField( child: TextField(
controller: _controller, controller: _controller,
focusNode: _focusNode, focusNode: _focusNode,
enabled: !_isLoading, enabled: !_isLoading && !_isApplied,
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
onChanged: (_) { onChanged: (_) {
if (_errorText != null) { if (_errorText != null) {
setState(() => _errorText = null); setState(() => _errorText = null);
} }
}, },
onSubmitted: (_) => _apply(), onSubmitted: (_) {
if (!_isApplied) _apply();
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: s.enter_promo_code, hintText: s.enter_promo_code,
hintStyle: const TextStyle( hintStyle: const TextStyle(
@@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
vertical: 14, vertical: 14,
), ),
), ),
style: const TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
color: Color(0xFF24303F), color: _isApplied
? const Color(0xFF6B7684)
: const Color(0xFF24303F),
), ),
), ),
), ),
@@ -213,13 +347,17 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
); );
} }
Widget _buildHint() { Widget _buildHint(S s) {
final String text; final String text;
final Color color; final Color color;
if (_errorText != null) { if (_errorText != null) {
text = _errorText!; text = _errorText!;
color = appTheme.red; color = appTheme.red;
} else if (_isApplied && _discountPercent != null) {
final amount = (_discountAmountRub ?? 0).toStringAsFixed(0);
text = s.promo_discount_hint(_discountPercent!, amount);
color = const Color(0xFF27A06B);
} else { } else {
text = ''; text = '';
color = Colors.transparent; color = Colors.transparent;
@@ -264,6 +402,50 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
); );
} }
Widget _buildContinueButton(S s) {
return SizedBox(
height: 50,
child: FilledButton(
onPressed: _isLoading ? null : _onContinueApplied,
style: FilledButton.styleFrom(
backgroundColor: appTheme.blueA400,
foregroundColor: appTheme.whiteA700,
disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: Text(s.continue_),
),
);
}
Widget _buildRemoveButton(S s) {
return SizedBox(
height: 46,
child: TextButton(
onPressed: _isLoading ? null : _onRemove,
style: TextButton.styleFrom(
foregroundColor: appTheme.blueA400,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontSize: 14),
),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: appTheme.blueA400,
),
)
: Text(s.promo_remove),
),
);
}
Widget _buildSkipButton(S s) { Widget _buildSkipButton(S s) {
return SizedBox( return SizedBox(
height: 46, height: 46,

View File

@@ -1,5 +1,11 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/services/chat_service.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart';
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП. /// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
class PaymentRepository { class PaymentRepository {
@@ -15,4 +21,67 @@ class PaymentRepository {
body: const {'payment_provider': 'alfa-bank'}, body: const {'payment_provider': 'alfa-bank'},
); );
} }
/// Список applied-промокодов текущего пользователя.
Future<PromoAppliedListResponse> listAppliedPromoCodes() {
return chatService.listAppliedPromoCodes();
}
/// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети).
Future<PromoCodeModel?> findAppliedPromoForPlan(int planId) async {
try {
final response = await listAppliedPromoCodes();
for (final promo in response.promoCodes) {
if (promo.chosenPlanId == planId && promo.state == 'applied') {
return promo;
}
}
return null;
} catch (_) {
return null;
}
}
/// Preview-валидация промокода без применения.
Future<PromoValidateResponse> validatePromoCode({
required int planId,
required String promoCode,
}) {
return chatService.validatePromoCode(
id: planId,
body: {'promo_code': promoCode},
);
}
/// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа.
Future<PromoApplyResponse> applyPromoCode({
required int planId,
required String promoCode,
}) async {
try {
return await chatService.applyPromoCode(
id: planId,
body: {'promo_code': promoCode},
);
} on DioException catch (e) {
final data = e.response?.data;
if (data is Map<String, dynamic>) {
return PromoApplyResponse.fromJson(data);
}
rethrow;
}
}
/// Удаляет применённый промокод (idempotent).
Future<PromoRemoveResponse> removePromoCode({required int planId}) async {
try {
return await chatService.removePromoCode(id: planId);
} on DioException catch (e) {
final data = e.response?.data;
if (data is Map<String, dynamic>) {
return PromoRemoveResponse.fromJson(data);
}
rethrow;
}
}
} }

View File

@@ -19,6 +19,9 @@ class ProfileCubit extends Cubit<ProfileState> {
final ConsultantRepository consultantRepository; final ConsultantRepository consultantRepository;
String _currentPeriod = '1'; String _currentPeriod = '1';
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
static ProfileCubit? myProfileCubit;
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading()); ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
Future<void> getProfileBySlug(String slug) async { Future<void> getProfileBySlug(String slug) async {
@@ -49,6 +52,7 @@ class ProfileCubit extends Cubit<ProfileState> {
} }
Future<void> getMyProfile({String period = '1'}) async { Future<void> getMyProfile({String period = '1'}) async {
myProfileCubit = this;
emit(const ProfileState.loading()); emit(const ProfileState.loading());
_currentPeriod = period; _currentPeriod = period;
try { try {
@@ -74,6 +78,22 @@ class ProfileCubit extends Cubit<ProfileState> {
} }
} }
/// Reloads only the blog posts list without resetting the whole profile screen.
Future<void> reloadBlogPosts() async {
final currentState = state;
if (currentState is! ProfileStateLoaded) {
return;
}
try {
final blogPosts = await profileRepository.getBlogPostsByAuthorSlug(slug: currentState.profile.slug);
emit(currentState.copyWith(blogPosts: blogPosts));
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
}
}
void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) { void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) {
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts]; List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
final int index = feedBlogPostList.indexOf(feedBlogPostModel); final int index = feedBlogPostList.indexOf(feedBlogPostModel);

View File

@@ -436,8 +436,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell( child: InkWell(
onTap: () { onTap: () async {
GoRouter.of(context).pushNamed('blogPostCreateScreen'); await GoRouter.of(context).pushNamed('blogPostCreateScreen');
if (mounted) {
await profileCubit.reloadBlogPosts();
}
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Ink( child: Ink(

View File

@@ -24,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget {
final int index; final int index;
final ProfileCubit profileCubit; final ProfileCubit profileCubit;
const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit}); const ProfileBlogPostWidget({
super.key,
required this.index,
required this.profileCubit,
});
@override @override
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState(); State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
@@ -35,8 +39,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
late int totalLikes; late int totalLikes;
int currentGalleryIndex = 0; int currentGalleryIndex = 0;
void _openGallery(List<String> imageUrls, int index) { void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
);
} }
@override @override
@@ -56,9 +63,13 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), onTap: () =>
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
child: Ink( child: Ink(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -74,9 +85,12 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
foregroundImage: NetworkImage( foregroundImage: NetworkImage(
post.author.authorProfileIcon.contains('https') post.author.authorProfileIcon.contains('https')
? post.author.authorProfileIcon ? post.author.authorProfileIcon
: sl<AppConfig>().baseUrl + post.author.authorProfileIcon, : sl<AppConfig>().baseUrl +
post.author.authorProfileIcon,
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
), ),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Column( Column(
@@ -84,12 +98,19 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
children: [ children: [
Text( Text(
post.author.authorName, post.author.authorName,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), style: const TextStyle(
height: 1,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', '${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), style: const TextStyle(
height: 1,
color: Colors.black45,
),
), ),
], ],
), ),
@@ -101,6 +122,7 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
id: post.id, id: post.id,
feedBlogPostModel: post, feedBlogPostModel: post,
userSlug: post.author.authorSlug, userSlug: post.author.authorSlug,
onPostsChanged: widget.profileCubit.reloadBlogPosts,
), ),
], ],
), ),
@@ -111,12 +133,17 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text(
post.title, post.title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Colors.black87,
),
), ),
), ),
], ],
if (post.title.isEmpty) const SizedBox(height: 8), if (post.title.isEmpty) const SizedBox(height: 8),
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), if (post.title.isNotEmpty && post.description.isNotEmpty)
const SizedBox(height: 8),
if (post.description.isNotEmpty) if (post.description.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
@@ -131,17 +158,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
padding: EdgeInsets.symmetric(horizontal: 12), padding: EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text(
'Показать ещё', 'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
), ),
), ),
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), if (post.title.isNotEmpty || post.description.isNotEmpty)
const SizedBox(height: 8),
BlogPostMediaContent( BlogPostMediaContent(
galleryImages: post.images, galleryImages: post.images,
imageUrl: imageUrl, imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, ? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
: null,
currentGalleryIndex: currentGalleryIndex, currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery, heroTag: 'profile-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'profile-blog-post-${post.id}-cover'
: null,
),
onGalleryChanged: (index) { onGalleryChanged: (index) {
setState(() { setState(() {
currentGalleryIndex = index; currentGalleryIndex = index;
@@ -154,18 +198,35 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
child: Row( child: Row(
children: [ children: [
FeedIconButton( FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: totalLikes.toString(), text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, iconColor: isLiked
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, ? Colors.red.withValues(alpha: 0.8)
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, : null,
backgroundColor: isLiked
? Colors.red.withValues(alpha: 0.1)
: null,
textColor: isLiked
? Colors.red.withValues(alpha: 0.8)
: null,
onTap: () async { onTap: () async {
setState(() { setState(() {
isLiked = !isLiked; isLiked = !isLiked;
isLiked ? totalLikes++ : totalLikes--; isLiked ? totalLikes++ : totalLikes--;
}); });
isLiked ? await context.read<FeedCubit>().likePost(post.id) : await context.read<FeedCubit>().unlikedPost(post.id); isLiked
widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true); ? 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), const SizedBox(width: 8),
@@ -174,10 +235,15 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
'assets/icons/comment.svg', 'assets/icons/comment.svg',
height: 20, height: 20,
width: 20, width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), colorFilter: ColorFilter.mode(
appTheme.gray600,
BlendMode.srcIn,
),
), ),
text: post.totalComments.toString(), text: post.totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), onTap: () => GoRouter.of(
context,
).pushNamed('blogPostScreen', extra: post.id),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FeedIconButton( FeedIconButton(
@@ -185,24 +251,38 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
height: 20, height: 20,
width: 20, width: 20,
'assets/icons/share.svg', 'assets/icons/share.svg',
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), colorFilter: ColorFilter.mode(
appTheme.gray600,
BlendMode.srcIn,
),
), ),
onTap: () { onTap: () {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share( SharePlus.instance.share(
ShareParams( ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'), uri: Uri.parse(
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, '${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) & box.size,
), ),
); );
}, },
), ),
const Spacer(), const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), Icon(
Icons.visibility_rounded,
size: 16,
color: appTheme.gray500,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
post.totalViews.toString(), post.totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
), ),
], ],
), ),

View File

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

View File

@@ -27,24 +27,27 @@ class MessageLookup extends MessageLookupByLibrary {
static String m2(planName, name) => static String m2(planName, name) =>
"План «${planName}» с пользователем ${name} начался"; "План «${planName}» с пользователем ${name} начался";
static String m3(step) => "шаг ${step} из 2"; static String m3(percent, amount) =>
"Discount ${percent}% — you save ${amount} ₽";
static String m4(planName, name) => static String m4(step) => "шаг ${step} из 2";
static String m5(planName, name) =>
"Завтра начинается план «${planName}» с пользователем ${name}"; "Завтра начинается план «${planName}» с пользователем ${name}";
static String m5(planName) => static String m6(planName) =>
"Завтра начнется прохождение плана «${planName}»"; "Завтра начнется прохождение плана «${planName}»";
static String m6(name, planName) => static String m7(name, planName) =>
"Пользователь ${name} отменил подписку на план «${planName}»"; "Пользователь ${name} отменил подписку на план «${planName}»";
static String m7(name, planName) => static String m8(name, planName) =>
"Пользователь ${name} оплатил план «${planName}»"; "Пользователь ${name} оплатил план «${planName}»";
static String m8(name, planName) => static String m9(name, planName) =>
"Пользователь ${name} подписался на план «${planName}»"; "Пользователь ${name} подписался на план «${planName}»";
static String m9(planName) => "Вы отменили подписку на план «${planName}»"; static String m10(planName) => "Вы отменили подписку на план «${planName}»";
final messages = _notInlinedMessages(_notInlinedMessages); final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{ static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -715,10 +718,21 @@ class MessageLookup extends MessageLookupByLibrary {
"Разница в ценах, это комиссия Итвио", "Разница в ценах, это комиссия Итвио",
), ),
"profile": MessageLookupByLibrary.simpleMessage("Профиль"), "profile": MessageLookupByLibrary.simpleMessage("Профиль"),
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
"The discount will be applied at checkout",
),
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
"Promo code applied!",
),
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
"promo_discount_hint": m3,
"promo_invalid": MessageLookupByLibrary.simpleMessage( "promo_invalid": MessageLookupByLibrary.simpleMessage(
"Promo code not found or expired", "Promo code not found or expired",
), ),
"promo_remove": MessageLookupByLibrary.simpleMessage("Remove promo code"),
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
"Could not remove the promo code. Try again later",
),
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"), "prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
"proteins": MessageLookupByLibrary.simpleMessage("Белки"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"),
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
@@ -809,7 +823,7 @@ class MessageLookup extends MessageLookupByLibrary {
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
"Пошаговый фоторецепт", "Пошаговый фоторецепт",
), ),
"step_widget": m3, "step_widget": m4,
"successfully_published": MessageLookupByLibrary.simpleMessage( "successfully_published": MessageLookupByLibrary.simpleMessage(
"успешно опубликован", "успешно опубликован",
), ),
@@ -834,8 +848,8 @@ class MessageLookup extends MessageLookupByLibrary {
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
"tommorowPlanWithUserWillStart": m4, "tommorowPlanWithUserWillStart": m5,
"tomorrowYourPlanWillStart": m5, "tomorrowYourPlanWillStart": m6,
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
@@ -869,7 +883,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Верхние показатели давления:", "Верхние показатели давления:",
), ),
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
"userCanceldThePlanSubscription": m6, "userCanceldThePlanSubscription": m7,
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
), ),
@@ -877,8 +891,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage( MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана.", "Пользователь подтвердил прохождения плана.",
), ),
"userPaidThePlan": m7, "userPaidThePlan": m8,
"userSubscribedToPlan": m8, "userSubscribedToPlan": m9,
"user_authorization": MessageLookupByLibrary.simpleMessage( "user_authorization": MessageLookupByLibrary.simpleMessage(
"Аутентификация", "Аутентификация",
), ),
@@ -1008,7 +1022,7 @@ class MessageLookup extends MessageLookupByLibrary {
"youCanComplain": MessageLookupByLibrary.simpleMessage( "youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу", "Если вас что-то не устроило, вы можете подать жалобу",
), ),
"youCanceledSubscriptionToPlan": m9, "youCanceledSubscriptionToPlan": m10,
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
"Вы уверены, что хотите отменить заказ?", "Вы уверены, что хотите отменить заказ?",
), ),

View File

@@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary { class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'ru'; String get localeName => 'ru';
static String m3(percent, amount) =>
"Скидка ${percent}% — вы экономите ${amount} ₽";
final messages = _notInlinedMessages(_notInlinedMessages); final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{ static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"), "Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
@@ -322,10 +325,21 @@ class MessageLookup extends MessageLookupByLibrary {
"Разница в ценах, это комиссия Итвио", "Разница в ценах, это комиссия Итвио",
), ),
"profile": MessageLookupByLibrary.simpleMessage("Профиль"), "profile": MessageLookupByLibrary.simpleMessage("Профиль"),
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
"Скидка учтётся при оплате",
),
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
"Промокод применён!",
),
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
"promo_discount_hint": m3,
"promo_invalid": MessageLookupByLibrary.simpleMessage( "promo_invalid": MessageLookupByLibrary.simpleMessage(
"Промокод не найден или истёк", "Промокод не найден или истёк",
), ),
"promo_remove": MessageLookupByLibrary.simpleMessage("Убрать промокод"),
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
"Не удалось убрать промокод. Попробуйте позже",
),
"proteins": MessageLookupByLibrary.simpleMessage("Белки"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"),
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"), "publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),

View File

@@ -4264,6 +4264,56 @@ class S {
); );
} }
/// `Promo code applied!`
String get promo_applied_title {
return Intl.message(
'Promo code applied!',
name: 'promo_applied_title',
desc: '',
args: [],
);
}
/// `The discount will be applied at checkout`
String get promo_applied_subtitle {
return Intl.message(
'The discount will be applied at checkout',
name: 'promo_applied_subtitle',
desc: '',
args: [],
);
}
/// `Remove promo code`
String get promo_remove {
return Intl.message(
'Remove promo code',
name: 'promo_remove',
desc: '',
args: [],
);
}
/// `Discount {percent}% — you save {amount} ₽`
String promo_discount_hint(int percent, String amount) {
return Intl.message(
'Discount $percent% — you save $amount ₽',
name: 'promo_discount_hint',
desc: '',
args: [percent, amount],
);
}
/// `Could not remove the promo code. Try again later`
String get promo_remove_failed {
return Intl.message(
'Could not remove the promo code. Try again later',
name: 'promo_remove_failed',
desc: '',
args: [],
);
}
/// `Loading…` /// `Loading…`
String get video_call_screen_loading_title { String get video_call_screen_loading_title {
return Intl.message( return Intl.message(

View File

@@ -502,6 +502,17 @@
"promo_code": "Promo code", "promo_code": "Promo code",
"enter_promo_code": "Enter promo code", "enter_promo_code": "Enter promo code",
"promo_invalid": "Promo code not found or expired", "promo_invalid": "Promo code not found or expired",
"promo_applied_title": "Promo code applied!",
"promo_applied_subtitle": "The discount will be applied at checkout",
"promo_remove": "Remove promo code",
"promo_discount_hint": "Discount {percent}% — you save {amount} ₽",
"@promo_discount_hint": {
"placeholders": {
"percent": {"type": "int"},
"amount": {"type": "String"}
}
},
"promo_remove_failed": "Could not remove the promo code. Try again later",
"video_call_screen_loading_title": "Loading…", "video_call_screen_loading_title": "Loading…",
"video_call_screen_connecting_title": "Connecting to\nthe consultation", "video_call_screen_connecting_title": "Connecting to\nthe consultation",
"video_call_screen_connecting_subtitle": "This may take a few seconds", "video_call_screen_connecting_subtitle": "This may take a few seconds",

View File

@@ -246,6 +246,17 @@
"promo_code": "Промокод", "promo_code": "Промокод",
"enter_promo_code": "Введите промокод", "enter_promo_code": "Введите промокод",
"promo_invalid": "Промокод не найден или истёк", "promo_invalid": "Промокод не найден или истёк",
"promo_applied_title": "Промокод применён!",
"promo_applied_subtitle": "Скидка учтётся при оплате",
"promo_remove": "Убрать промокод",
"promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽",
"@promo_discount_hint": {
"placeholders": {
"percent": {"type": "int"},
"amount": {"type": "String"}
}
},
"promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже",
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
"video_call_screen_loading_title": "Загрузка…", "video_call_screen_loading_title": "Загрузка…",
"video_call_screen_connecting_title": "Подключение\nк консультации", "video_call_screen_connecting_title": "Подключение\nк консультации",

View File

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.36+125 version: 1.3.39+128
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@@ -21,10 +21,18 @@ void main() {
setUp(() { setUp(() {
blogPostRepository = FixtureBlogPostRepository(); blogPostRepository = FixtureBlogPostRepository();
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); blogPostCreateCubit = BlogPostCreateCubit(
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json'); blogPostRepository: blogPostRepository,
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json'); );
deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json'); fullCreateFixture = FixtureReader.readAsMap(
'blog_post_create/create_request_full.json',
);
updateFixture = FixtureReader.readAsMap(
'blog_post_create/update_request.json',
);
deleteFixture = FixtureReader.readAsMap(
'blog_post_create/delete_request.json',
);
}); });
tearDown(() { tearDown(() {
@@ -41,7 +49,9 @@ void main() {
); );
} }
testWidgets('should handle blog post creation with all fields', (WidgetTester tester) async { testWidgets('should handle blog post creation with all fields', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget formWidget = Column( Widget formWidget = Column(
children: [ children: [
@@ -69,7 +79,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
videoUrl: fullCreateFixture['video_url'] as String?, videoUrl: fullCreateFixture['video_url'] as String?,
image: null,
); );
}, },
child: const Text('Опубликовать'), child: const Text('Опубликовать'),
@@ -81,9 +90,18 @@ void main() {
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); fullCreateFixture['title'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
fullCreateFixture['description_md'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
fullCreateFixture['video_url'] as String,
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
@@ -96,7 +114,9 @@ void main() {
); );
}); });
testWidgets('should handle blog post creation with image', (WidgetTester tester) async { testWidgets('should handle blog post creation with image', (
WidgetTester tester,
) async {
// Arrange // Arrange
final expectedPayload = Map<String, dynamic>.from(fullCreateFixture); final expectedPayload = Map<String, dynamic>.from(fullCreateFixture);
expectedPayload['video_url'] = null; expectedPayload['video_url'] = null;
@@ -129,7 +149,11 @@ void main() {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), Icon(
Icons.add_photo_alternate,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8), SizedBox(height: 8),
Text('Добавить изображение'), Text('Добавить изображение'),
], ],
@@ -145,7 +169,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
videoUrl: null, videoUrl: null,
image: null, // В реальном тесте здесь был бы XFile
); );
}, },
child: const Text('Опубликовать'), child: const Text('Опубликовать'),
@@ -157,15 +180,24 @@ void main() {
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
fullCreateFixture['title'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
fullCreateFixture['description_md'] as String,
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
await tester.pump(); await tester.pump();
// Assert // Assert
expect(blogPostRepository.createPayloads.single.toJson(), expectedPayload); expect(
blogPostRepository.createPayloads.single.toJson(),
expectedPayload,
);
}); });
testWidgets('should handle blog post update', (WidgetTester tester) async { testWidgets('should handle blog post update', (WidgetTester tester) async {
@@ -175,7 +207,12 @@ void main() {
state: 'published', state: 'published',
image: 'original_image.jpg', image: 'original_image.jpg',
description: 'Original Description', description: 'Original Description',
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), author: const Author(
authorSlug: 'test-author',
authorName: 'Test Author',
authorProfileIcon: 'avatar.jpg',
authorIsMe: true,
),
videoUrl: 'https://youtube.com/watch?v=original', videoUrl: 'https://youtube.com/watch?v=original',
createdAt: DateTime.now(), createdAt: DateTime.now(),
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
@@ -216,8 +253,9 @@ void main() {
title: updateFixture['title'] as String?, title: updateFixture['title'] as String?,
description: updateFixture['description_md'] as String?, description: updateFixture['description_md'] as String?,
videoUrl: updateFixture['video_url'] as String?, videoUrl: updateFixture['video_url'] as String?,
image: null, existingMediaIds: List<int>.from(
oldImageUrl: updateFixture['old_image_url'] as String?, updateFixture['existing_media_ids'] as List,
),
); );
}, },
child: const Text('Обновить'), child: const Text('Обновить'),
@@ -229,9 +267,18 @@ void main() {
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String); updateFixture['title'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
updateFixture['description_md'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
updateFixture['video_url'] as String,
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
@@ -241,7 +288,9 @@ void main() {
expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture); expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture);
}); });
testWidgets('should handle blog post deletion', (WidgetTester tester) async { testWidgets('should handle blog post deletion', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget deleteWidget = ElevatedButton( Widget deleteWidget = ElevatedButton(
key: const Key('deleteBlogPostButton'), key: const Key('deleteBlogPostButton'),
@@ -262,18 +311,21 @@ void main() {
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']); expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
}); });
testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async { testWidgets('should handle loading state during blog post creation', (
WidgetTester tester,
) async {
// Arrange // Arrange
blogPostRepository.createDelay = const Duration(milliseconds: 50); blogPostRepository.createDelay = const Duration(milliseconds: 50);
Widget loadingWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( Widget loadingWidget =
builder: (context, state) { BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateLoading) { builder: (context, state) {
return const Center(child: CircularProgressIndicator()); if (state.isLoading) {
} return const Center(child: CircularProgressIndicator());
return const Text('Not Loading'); }
}, return const Text('Not Loading');
); },
);
// Act // Act
await tester.pumpWidget(createTestWidget(loadingWidget)); await tester.pumpWidget(createTestWidget(loadingWidget));
@@ -284,7 +336,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: null, videoUrl: null,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
await tester.pump(); await tester.pump();
await tester.pump(); // Need to pump twice to get the loading state await tester.pump(); // Need to pump twice to get the loading state
@@ -297,18 +348,21 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
testWidgets('should handle error state during blog post creation', (WidgetTester tester) async { testWidgets('should handle error state during blog post creation', (
WidgetTester tester,
) async {
// Arrange // Arrange
blogPostRepository.createError = Exception('Network error'); blogPostRepository.createError = Exception('Network error');
Widget errorWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( Widget errorWidget =
builder: (context, state) { BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateError) { builder: (context, state) {
return Center(child: Text('Error: ${state.errorMessage}')); if (state.errorMessage != null) {
} return Center(child: Text('Error: ${state.errorMessage}'));
return const Text('No Error'); }
}, return const Text('No Error');
); },
);
// Act // Act
await tester.pumpWidget(createTestWidget(errorWidget)); await tester.pumpWidget(createTestWidget(errorWidget));
@@ -319,7 +373,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: null, videoUrl: null,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
await tester.pump(); await tester.pump();
await tester.pump(); // Need to pump twice to get the error state await tester.pump(); // Need to pump twice to get the error state
@@ -328,16 +381,21 @@ void main() {
expect(find.textContaining('Error:'), findsOneWidget); expect(find.textContaining('Error:'), findsOneWidget);
}); });
testWidgets('should handle success state after blog post creation', (WidgetTester tester) async { testWidgets('should handle success state after blog post creation', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget successWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( Widget successWidget =
builder: (context, state) { BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateLoaded) { builder: (context, state) {
return const Center(child: Text('Blog post created successfully!')); if (state.isSuccess) {
} return const Center(
return const Text('Not Loaded'); child: Text('Blog post created successfully!'),
}, );
); }
return const Text('Not Loaded');
},
);
// Act // Act
await tester.pumpWidget(createTestWidget(successWidget)); await tester.pumpWidget(createTestWidget(successWidget));
@@ -348,7 +406,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: null, videoUrl: null,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
await tester.pump(); await tester.pump();
await tester.pump(); // Need to pump twice to get the success state await tester.pump(); // Need to pump twice to get the success state
@@ -357,23 +414,35 @@ void main() {
expect(find.text('Blog post created successfully!'), findsOneWidget); expect(find.text('Blog post created successfully!'), findsOneWidget);
}); });
testWidgets('should validate form fields before submission', (WidgetTester tester) async { testWidgets('should validate form fields before submission', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget validationWidget = Column( Widget validationWidget = Column(
children: [ children: [
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок не может быть пустым'), decoration: const InputDecoration(
labelText: 'Заголовок',
errorText: 'Заголовок не может быть пустым',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenContentTextField, key: AppKeys.blogPostCreateScreenContentTextField,
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание не может быть пустым'), decoration: const InputDecoration(
labelText: 'Описание',
errorText: 'Описание не может быть пустым',
),
maxLines: 6, maxLines: 6,
minLines: 4, minLines: 4,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
),
], ],
); );
@@ -386,7 +455,9 @@ void main() {
expect(find.text('Описание не может быть пустым'), findsOneWidget); expect(find.text('Описание не может быть пустым'), findsOneWidget);
}); });
testWidgets('should handle image picker interaction', (WidgetTester tester) async { testWidgets('should handle image picker interaction', (
WidgetTester tester,
) async {
// Arrange // Arrange
bool imagePickerCalled = false; bool imagePickerCalled = false;
Widget imagePickerWidget = GestureDetector( Widget imagePickerWidget = GestureDetector(
@@ -425,16 +496,25 @@ void main() {
expect(imagePickerCalled, isTrue); expect(imagePickerCalled, isTrue);
}); });
testWidgets('should handle video URL validation', (WidgetTester tester) async { testWidgets('should handle video URL validation', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget videoValidationWidget = Column( Widget videoValidationWidget = Column(
children: [ children: [
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField, key: AppKeys.blogPostCreateScreenVideoLinkTextField,
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку на видео'), decoration: const InputDecoration(
labelText: 'Ссылка на видео',
errorText: 'Введите корректную ссылку на видео',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
),
], ],
); );
@@ -446,7 +526,9 @@ void main() {
expect(find.text('Введите корректную ссылку на видео'), findsOneWidget); expect(find.text('Введите корректную ссылку на видео'), findsOneWidget);
}); });
testWidgets('should handle form submission with empty fields', (WidgetTester tester) async { testWidgets('should handle form submission with empty fields', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget emptyFormWidget = Column( Widget emptyFormWidget = Column(
children: [ children: [
@@ -465,7 +547,11 @@ void main() {
ElevatedButton( ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton, key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () async { onPressed: () async {
await blogPostCreateCubit.createBlogPost(title: '', videoUrl: '', description: '', image: null); await blogPostCreateCubit.createBlogPost(
title: '',
videoUrl: '',
description: '',
);
}, },
child: const Text('Опубликовать'), child: const Text('Опубликовать'),
), ),
@@ -479,14 +565,14 @@ void main() {
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
await tester.pump(); await tester.pump();
// Assert // Assert — invalid empty payload must not reach the repository
expect( expect(blogPostRepository.createPayloads, isEmpty);
blogPostRepository.createPayloads.single.toJson(), expect(blogPostCreateCubit.state.hasValidationErrors, isTrue);
{'title': '', 'description_md': '', 'video_url': '', 'image_name': null},
);
}); });
testWidgets('should handle blog post creation with long content', (WidgetTester tester) async { testWidgets('should handle blog post creation with long content', (
WidgetTester tester,
) async {
// Arrange // Arrange
const longContent = const longContent =
'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. ' 'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. '
@@ -510,7 +596,11 @@ void main() {
ElevatedButton( ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton, key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () async { onPressed: () async {
await blogPostCreateCubit.createBlogPost(title: 'Test Title', videoUrl: null, description: longContent, image: null); await blogPostCreateCubit.createBlogPost(
title: 'Test Title',
videoUrl: null,
description: longContent,
);
}, },
child: const Text('Опубликовать'), child: const Text('Опубликовать'),
), ),
@@ -521,23 +611,26 @@ void main() {
await tester.pumpWidget(createTestWidget(longContentWidget)); await tester.pumpWidget(createTestWidget(longContentWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title'); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), longContent); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
'Test Title',
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
longContent,
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
await tester.pump(); await tester.pump();
// Assert // Assert
expect( expect(blogPostRepository.createPayloads.single.toJson(), {
blogPostRepository.createPayloads.single.toJson(), 'title': 'Test Title',
{ 'description_md': longContent,
'title': 'Test Title', 'video_url': null,
'description_md': longContent, 'image_name': null,
'video_url': null, });
'image_name': null,
},
);
}); });
}); });
} }

View File

@@ -14,7 +14,9 @@ void main() {
setUp(() { setUp(() {
blogPostRepository = FixtureBlogPostRepository(); blogPostRepository = FixtureBlogPostRepository();
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); blogPostCreateCubit = BlogPostCreateCubit(
blogPostRepository: blogPostRepository,
);
}); });
tearDown(() { tearDown(() {
@@ -31,7 +33,9 @@ void main() {
); );
} }
testWidgets('should create BlogPostCreateCubit successfully', (WidgetTester tester) async { testWidgets('should create BlogPostCreateCubit successfully', (
WidgetTester tester,
) async {
// Arrange & Act // Arrange & Act
final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
@@ -41,13 +45,18 @@ void main() {
cubit.close(); cubit.close();
}); });
testWidgets('should display basic blog post form fields', (WidgetTester tester) async { testWidgets('should display basic blog post form fields', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget formWidget = Column( Widget formWidget = Column(
children: [ children: [
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), decoration: const InputDecoration(
labelText: 'Заголовок',
hintText: 'Введите заголовок поста',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -62,7 +71,11 @@ void main() {
decoration: const InputDecoration(labelText: 'Ссылка на видео'), decoration: const InputDecoration(labelText: 'Ссылка на видео'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
),
], ],
); );
@@ -71,17 +84,34 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Assert // Assert
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); expect(
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); findsOneWidget,
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); );
expect(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
findsOneWidget,
);
}); });
testWidgets('should update title when user types in title field', (WidgetTester tester) async { testWidgets('should update title when user types in title field', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget formWidget = TextFormField( Widget formWidget = TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), decoration: const InputDecoration(
labelText: 'Заголовок',
hintText: 'Введите заголовок поста',
),
); );
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
@@ -90,36 +120,47 @@ void main() {
const testTitle = 'Тестовый заголовок поста'; const testTitle = 'Тестовый заголовок поста';
// Act // Act
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), testTitle); await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
testTitle,
);
await tester.pump(); await tester.pump();
// Assert // Assert
expect(find.text(testTitle), findsOneWidget); expect(find.text(testTitle), findsOneWidget);
}); });
testWidgets('should update description when user types in description field', (WidgetTester tester) async { testWidgets(
// Arrange 'should update description when user types in description field',
Widget formWidget = TextFormField( (WidgetTester tester) async {
key: AppKeys.blogPostCreateScreenContentTextField, // Arrange
decoration: const InputDecoration(labelText: 'Описание'), Widget formWidget = TextFormField(
maxLines: 6, key: AppKeys.blogPostCreateScreenContentTextField,
minLines: 4, decoration: const InputDecoration(labelText: 'Описание'),
); maxLines: 6,
minLines: 4,
);
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
const testDescription = 'Это тестовое описание блог поста'; const testDescription = 'Это тестовое описание блог поста';
// Act // Act
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), testDescription); await tester.enterText(
await tester.pump(); find.byKey(AppKeys.blogPostCreateScreenContentTextField),
testDescription,
);
await tester.pump();
// Assert // Assert
expect(find.text(testDescription), findsOneWidget); expect(find.text(testDescription), findsOneWidget);
}); },
);
testWidgets('should update video URL when user types in video field', (WidgetTester tester) async { testWidgets('should update video URL when user types in video field', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget formWidget = TextFormField( Widget formWidget = TextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField, key: AppKeys.blogPostCreateScreenVideoLinkTextField,
@@ -132,16 +173,27 @@ void main() {
const testVideoUrl = 'https://youtube.com/watch?v=example'; const testVideoUrl = 'https://youtube.com/watch?v=example';
// Act // Act
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), testVideoUrl); await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
testVideoUrl,
);
await tester.pump(); await tester.pump();
// Assert // Assert
expect(find.text(testVideoUrl), findsOneWidget); expect(find.text(testVideoUrl), findsOneWidget);
}); });
testWidgets('should display form labels correctly', (WidgetTester tester) async { testWidgets('should display form labels correctly', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget formWidget = const Column(children: [Text('Заголовок'), Text('Описание'), Text('Ссылка на видео')]); Widget formWidget = const Column(
children: [
Text('Заголовок'),
Text('Описание'),
Text('Ссылка на видео'),
],
);
// Act // Act
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
@@ -153,11 +205,16 @@ void main() {
expect(find.text('Ссылка на видео'), findsOneWidget); expect(find.text('Ссылка на видео'), findsOneWidget);
}); });
testWidgets('should display placeholder text in title field', (WidgetTester tester) async { testWidgets('should display placeholder text in title field', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget formWidget = TextFormField( Widget formWidget = TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), decoration: const InputDecoration(
labelText: 'Заголовок',
hintText: 'Введите заголовок поста',
),
); );
// Act // Act
@@ -170,14 +227,21 @@ void main() {
testWidgets('should display create button', (WidgetTester tester) async { testWidgets('should display create button', (WidgetTester tester) async {
// Arrange // Arrange
Widget formWidget = ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')); Widget formWidget = ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
);
// Act // Act
await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpWidget(createTestWidget(formWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Assert // Assert
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); expect(
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
findsOneWidget,
);
expect(find.text('Опубликовать'), findsOneWidget); expect(find.text('Опубликовать'), findsOneWidget);
}); });
@@ -217,19 +281,26 @@ void main() {
testWidgets('should display error message', (WidgetTester tester) async { testWidgets('should display error message', (WidgetTester tester) async {
// Arrange // Arrange
Widget errorWidget = const Center(child: Text('Произошла ошибка при создании блог поста')); Widget errorWidget = const Center(
child: Text('Произошла ошибка при создании блог поста'),
);
// Act // Act
await tester.pumpWidget(createTestWidget(errorWidget)); await tester.pumpWidget(createTestWidget(errorWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Assert // Assert
expect(find.text('Произошла ошибка при создании блог поста'), findsOneWidget); expect(
find.text('Произошла ошибка при создании блог поста'),
findsOneWidget,
);
}); });
testWidgets('should display success message', (WidgetTester tester) async { testWidgets('should display success message', (WidgetTester tester) async {
// Arrange // Arrange
Widget successWidget = const Center(child: Text('Блог пост успешно создан!')); Widget successWidget = const Center(
child: Text('Блог пост успешно создан!'),
);
// Act // Act
await tester.pumpWidget(createTestWidget(successWidget)); await tester.pumpWidget(createTestWidget(successWidget));
@@ -245,12 +316,18 @@ void main() {
children: [ children: [
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'), decoration: const InputDecoration(
labelText: 'Заголовок',
errorText: 'Заголовок обязателен',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenContentTextField, key: AppKeys.blogPostCreateScreenContentTextField,
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'), decoration: const InputDecoration(
labelText: 'Описание',
errorText: 'Описание обязательно',
),
), ),
], ],
); );
@@ -264,13 +341,18 @@ void main() {
expect(find.text('Описание обязательно'), findsOneWidget); expect(find.text('Описание обязательно'), findsOneWidget);
}); });
testWidgets('should display form with all required fields', (WidgetTester tester) async { testWidgets('should display form with all required fields', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget completeForm = Column( Widget completeForm = Column(
children: [ children: [
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), decoration: const InputDecoration(
labelText: 'Заголовок',
hintText: 'Введите заголовок поста',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -285,7 +367,11 @@ void main() {
decoration: const InputDecoration(labelText: 'Ссылка на видео'), decoration: const InputDecoration(labelText: 'Ссылка на видео'),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
),
], ],
); );
@@ -294,13 +380,27 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Assert // Assert
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); expect(
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); findsOneWidget,
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); );
expect(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
findsOneWidget,
);
}); });
testWidgets('should display image upload widget', (WidgetTester tester) async { testWidgets('should display image upload widget', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget imageWidget = GestureDetector( Widget imageWidget = GestureDetector(
key: AppKeys.imageWidget, key: AppKeys.imageWidget,
@@ -373,68 +473,101 @@ void main() {
expect(imageUploadTapped, isTrue); expect(imageUploadTapped, isTrue);
}); });
testWidgets('should display blog post creation form with image and video options', (WidgetTester tester) async { testWidgets(
// Arrange 'should display blog post creation form with image and video options',
Widget completeForm = Column( (WidgetTester tester) async {
children: [ // Arrange
const Text('Заголовок'), Widget completeForm = Column(
const SizedBox(height: 8), children: [
TextFormField( const Text('Заголовок'),
key: AppKeys.blogPostCreateScreenTitleTextField, const SizedBox(height: 8),
decoration: const InputDecoration(hintText: 'Введите заголовок поста'), TextFormField(
), key: AppKeys.blogPostCreateScreenTitleTextField,
const SizedBox(height: 16), decoration: const InputDecoration(
const Text('Описание'), hintText: 'Введите заголовок поста',
const SizedBox(height: 8),
TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4),
const SizedBox(height: 16),
const Text('Изображение поста'),
const SizedBox(height: 8),
GestureDetector(
key: AppKeys.imageWidget,
onTap: () {},
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
), ),
child: const Center( ),
child: Column( const SizedBox(height: 16),
mainAxisAlignment: MainAxisAlignment.center, const Text('Описание'),
children: [ const SizedBox(height: 8),
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), TextFormField(
SizedBox(height: 8), key: AppKeys.blogPostCreateScreenContentTextField,
Text('Добавить изображение'), decoration: const InputDecoration(),
], maxLines: 6,
minLines: 4,
),
const SizedBox(height: 16),
const Text('Изображение поста'),
const SizedBox(height: 8),
GestureDetector(
key: AppKeys.imageWidget,
onTap: () {},
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8),
Text('Добавить изображение'),
],
),
), ),
), ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), const Text('Ссылка на видео'),
const Text('Ссылка на видео'), const SizedBox(height: 8),
const SizedBox(height: 8), TextFormField(
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()), key: AppKeys.blogPostCreateScreenVideoLinkTextField,
const SizedBox(height: 32), decoration: const InputDecoration(),
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), ),
], const SizedBox(height: 32),
); ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
),
],
);
// Act // Act
await tester.pumpWidget(createTestWidget(completeForm)); await tester.pumpWidget(createTestWidget(completeForm));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Assert // Assert
expect(find.text('Заголовок'), findsOneWidget); expect(find.text('Заголовок'), findsOneWidget);
expect(find.text('Описание'), findsOneWidget); expect(find.text('Описание'), findsOneWidget);
expect(find.text('Изображение поста'), findsOneWidget); expect(find.text('Изображение поста'), findsOneWidget);
expect(find.text('Ссылка на видео'), findsOneWidget); expect(find.text('Ссылка на видео'), findsOneWidget);
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); expect(
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
expect(find.byKey(AppKeys.imageWidget), findsOneWidget); findsOneWidget,
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); );
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); expect(
}); find.byKey(AppKeys.blogPostCreateScreenContentTextField),
findsOneWidget,
);
expect(find.byKey(AppKeys.imageWidget), findsOneWidget);
expect(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
findsOneWidget,
);
},
);
}); });
} }

View File

@@ -1,6 +1,8 @@
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@@ -14,6 +16,8 @@ void main() {
setUp(() { setUp(() {
mockRepository = MockBlogPostRepository(); mockRepository = MockBlogPostRepository();
cubit = BlogPostCreateCubit(blogPostRepository: mockRepository); cubit = BlogPostCreateCubit(blogPostRepository: mockRepository);
registerFallbackValue(<XFile>[]);
registerFallbackValue(<int>[]);
}); });
tearDown(() { tearDown(() {
@@ -21,63 +25,246 @@ void main() {
}); });
test('начальное состояние - BlogPostCreateState', () { test('начальное состояние - BlogPostCreateState', () {
expect(cubit.state, isA<BlogPostCreateState>()); expect(cubit.state, const BlogPostCreateState());
}); });
test('createBlogPost - успешное создание', () async { test('validateForm - пустая форма заполняет formError', () {
when(() => mockRepository.createBlogPost(any(), any(), any(), any())) final isValid = cubit.validateForm(
.thenAnswer((_) async => {}); title: '',
description: '',
videoUrl: '',
imageCount: 0,
);
final result = await cubit.createBlogPost(); expect(isValid, false);
expect(cubit.state.formError, BlogPostFormValidator.contentRequired);
verifyNever(
() => mockRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
);
});
test('validateForm - невалидное видео заполняет videoUrlError', () {
final isValid = cubit.validateForm(
title: 'Valid title',
description: '',
videoUrl: 'https://youtube.com/watch?v=abc',
imageCount: 0,
);
expect(isValid, false);
expect(
cubit.state.videoUrlError,
BlogPostFormValidator.videoUrlUnsupportedHost,
);
expect(cubit.state.formError, isNull);
});
test(
'createBlogPost - не вызывает репозиторий при ошибке валидации',
() async {
final result = await cubit.createBlogPost();
expect(result, false);
expect(cubit.state.hasValidationErrors, true);
verifyNever(
() => mockRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
);
},
);
test('createBlogPost - успешное создание', () async {
when(
() => mockRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
).thenAnswer((_) async => {});
final result = await cubit.createBlogPost(
title: 'Test Title',
description: 'Test Description',
);
expect(result, true); expect(result, true);
expect(cubit.state, isA<BlogPostCreateStateLoaded>()); expect(cubit.state.isSuccess, true);
verify(() => mockRepository.createBlogPost(null, null, null, null)).called(1); verify(
() => mockRepository.createBlogPost(
title: 'Test Title',
videoUrl: null,
description: 'Test Description',
images: const [],
),
).called(1);
}); });
test('createBlogPost - обработка ошибки', () async { test('createBlogPost - обработка ошибки', () async {
when(() => mockRepository.createBlogPost(any(), any(), any(), any())) when(
.thenThrow(Exception('Error')); () => mockRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
).thenThrow(Exception('Error'));
final result = await cubit.createBlogPost(); final result = await cubit.createBlogPost(
title: 'Test Title',
description: 'Test Description',
);
expect(result, false); expect(result, false);
expect(cubit.state, isA<BlogPostCreateStateError>()); expect(cubit.state.errorMessage, contains('Error'));
final errorState = cubit.state as BlogPostCreateStateError; expect(cubit.state.isSuccess, false);
expect(errorState.errorMessage, contains('Error'));
}); });
test(
'updateBlogPost - не вызывает репозиторий при ошибке валидации',
() async {
final result = await cubit.updateBlogPost(id: 1);
expect(result, false);
expect(cubit.state.hasValidationErrors, true);
expect(cubit.state.formError, BlogPostFormValidator.contentRequired);
verifyNever(
() => mockRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
);
},
);
test('updateBlogPost - невалидное видео не уходит в репозиторий', () async {
final result = await cubit.updateBlogPost(
id: 1,
title: 'Valid title',
videoUrl: 'https://youtube.com/watch?v=abc',
existingMediaIds: const [10],
);
expect(result, false);
expect(
cubit.state.videoUrlError,
BlogPostFormValidator.videoUrlUnsupportedHost,
);
verifyNever(
() => mockRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
);
});
test(
'updateBlogPost - короткий заголовок не уходит в репозиторий',
() async {
final result = await cubit.updateBlogPost(
id: 1,
title: 'ab',
existingMediaIds: const [10],
);
expect(result, false);
expect(cubit.state.titleError, BlogPostFormValidator.titleTooShort);
verifyNever(
() => mockRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
);
},
);
test('updateBlogPost - успешное обновление', () async { test('updateBlogPost - успешное обновление', () async {
const id = 1; const id = 1;
const title = 'Updated Title'; const title = 'Updated Title';
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any())) when(
.thenAnswer((_) async => {}); () => mockRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
).thenAnswer((_) async => {});
final result = await cubit.updateBlogPost(id: id, title: title); final result = await cubit.updateBlogPost(
id: id,
title: title,
existingMediaIds: [2, 1],
mediaIdsToDelete: [9],
);
expect(result, true); expect(result, true);
expect(cubit.state, isA<BlogPostCreateStateLoaded>()); expect(cubit.state.isSuccess, true);
verify(() => mockRepository.updateBlogPost(id, title, null, null, null, null)).called(1); verify(
() => mockRepository.updateBlogPost(
id: id,
title: title,
videoUrl: '',
description: '',
existingMediaIds: [2, 1],
newImages: const [],
mediaIdsToDelete: [9],
),
).called(1);
}); });
test('updateBlogPost - обработка ошибки', () async { test('updateBlogPost - обработка ошибки', () async {
const id = 1; const id = 1;
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any())) when(
.thenThrow(Exception('Error')); () => mockRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
).thenThrow(Exception('Error'));
final result = await cubit.updateBlogPost(id: id); final result = await cubit.updateBlogPost(id: id, title: 'Valid title');
expect(result, false); expect(result, false);
expect(cubit.state, isA<BlogPostCreateStateError>()); expect(cubit.state.errorMessage, contains('Error'));
}); });
test('deleteBlogPost - успешное удаление', () async { test('deleteBlogPost - успешное удаление', () async {
const id = 1; const id = 1;
when(() => mockRepository.deleteBlogPost(id)) when(() => mockRepository.deleteBlogPost(id)).thenAnswer((_) async => {});
.thenAnswer((_) async => {});
final result = await cubit.deleteBlogPost(id); final result = await cubit.deleteBlogPost(id);
@@ -88,13 +275,27 @@ void main() {
test('deleteBlogPost - обработка ошибки', () async { test('deleteBlogPost - обработка ошибки', () async {
const id = 1; const id = 1;
when(() => mockRepository.deleteBlogPost(id)) when(
.thenThrow(Exception('Error')); () => mockRepository.deleteBlogPost(id),
).thenThrow(Exception('Error'));
final result = await cubit.deleteBlogPost(id); final result = await cubit.deleteBlogPost(id);
expect(result, false); expect(result, false);
}); });
test('clearTitleError очищает titleError и formError', () {
cubit.validateForm(
title: '',
description: '',
videoUrl: '',
imageCount: 0,
);
expect(cubit.state.formError, isNotNull);
cubit.clearTitleError();
expect(cubit.state.formError, isNull);
});
}); });
} }

View File

@@ -21,10 +21,16 @@ void main() {
setUp(() { setUp(() {
blogPostRepository = FixtureBlogPostRepository(); blogPostRepository = FixtureBlogPostRepository();
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); blogPostCreateCubit = BlogPostCreateCubit(
blogPostRepository: blogPostRepository,
);
feedProbe = FeedReloadProbe(); feedProbe = FeedReloadProbe();
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json'); fullCreateFixture = FixtureReader.readAsMap(
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json'); 'blog_post_create/create_request_full.json',
);
updateFixture = FixtureReader.readAsMap(
'blog_post_create/update_request.json',
);
}); });
tearDown(() { tearDown(() {
@@ -41,11 +47,14 @@ void main() {
); );
} }
testWidgets('should complete full blog post creation flow', (WidgetTester tester) async { testWidgets('should complete full blog post creation flow', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>( Widget
fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
listener: (context, state) { listener: (context, state) {
if (state is BlogPostCreateStateLoaded) { if (state.isSuccess) {
feedProbe.loadFeedBlogPostList(); feedProbe.loadFeedBlogPostList();
} }
}, },
@@ -82,7 +91,11 @@ void main() {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), Icon(
Icons.add_photo_alternate,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8), SizedBox(height: 8),
Text('Добавить изображение'), Text('Добавить изображение'),
], ],
@@ -98,7 +111,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: fullCreateFixture['video_url'] as String?, videoUrl: fullCreateFixture['video_url'] as String?,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
}, },
child: const Text('Опубликовать'), child: const Text('Опубликовать'),
@@ -111,27 +123,46 @@ void main() {
await tester.pumpWidget(createTestWidget(fullFlowWidget)); await tester.pumpWidget(createTestWidget(fullFlowWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); fullCreateFixture['title'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
fullCreateFixture['description_md'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
fullCreateFixture['video_url'] as String,
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
await tester.pump(); await tester.pump();
// Assert // Assert
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture); expect(
blogPostRepository.createPayloads.single.toJson(),
fullCreateFixture,
);
expect(feedProbe.loadRequests, 1); expect(feedProbe.loadRequests, 1);
}); });
testWidgets('should handle blog post update flow', (WidgetTester tester) async { testWidgets('should handle blog post update flow', (
WidgetTester tester,
) async {
// Arrange // Arrange
final existingBlogPost = FeedBlogPostModel( final existingBlogPost = FeedBlogPostModel(
id: 1, id: 1,
state: 'published', state: 'published',
image: 'original_image.jpg', image: 'original_image.jpg',
description: 'Original Description', description: 'Original Description',
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), author: const Author(
authorSlug: 'test-author',
authorName: 'Test Author',
authorProfileIcon: 'avatar.jpg',
authorIsMe: true,
),
videoUrl: 'https://youtube.com/watch?v=original', videoUrl: 'https://youtube.com/watch?v=original',
createdAt: DateTime.now(), createdAt: DateTime.now(),
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
@@ -142,9 +173,10 @@ void main() {
wasLikedByMe: false, wasLikedByMe: false,
); );
Widget updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>( Widget
updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
listener: (context, state) { listener: (context, state) {
if (state is BlogPostCreateStateLoaded) { if (state.isSuccess) {
feedProbe.loadFeedBlogPostList(); feedProbe.loadFeedBlogPostList();
} }
}, },
@@ -161,13 +193,17 @@ void main() {
decoration: const InputDecoration(labelText: 'Описание'), decoration: const InputDecoration(labelText: 'Описание'),
maxLines: 6, maxLines: 6,
minLines: 4, minLines: 4,
controller: TextEditingController(text: existingBlogPost.description), controller: TextEditingController(
text: existingBlogPost.description,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField, key: AppKeys.blogPostCreateScreenVideoLinkTextField,
decoration: const InputDecoration(labelText: 'Ссылка на видео'), decoration: const InputDecoration(labelText: 'Ссылка на видео'),
controller: TextEditingController(text: existingBlogPost.videoUrl), controller: TextEditingController(
text: existingBlogPost.videoUrl,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
@@ -178,8 +214,9 @@ void main() {
title: updateFixture['title'] as String?, title: updateFixture['title'] as String?,
videoUrl: updateFixture['video_url'] as String?, videoUrl: updateFixture['video_url'] as String?,
description: updateFixture['description_md'] as String?, description: updateFixture['description_md'] as String?,
image: null, existingMediaIds: List<int>.from(
oldImageUrl: updateFixture['old_image_url'] as String?, updateFixture['existing_media_ids'] as List,
),
); );
}, },
child: const Text('Обновить'), child: const Text('Обновить'),
@@ -192,9 +229,18 @@ void main() {
await tester.pumpWidget(createTestWidget(updateFlowWidget)); await tester.pumpWidget(createTestWidget(updateFlowWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String); updateFixture['title'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
updateFixture['description_md'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
updateFixture['video_url'] as String,
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
@@ -205,7 +251,9 @@ void main() {
expect(feedProbe.loadRequests, 1); expect(feedProbe.loadRequests, 1);
}); });
testWidgets('should handle blog post deletion flow', (WidgetTester tester) async { testWidgets('should handle blog post deletion flow', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget deleteFlowWidget = Column( Widget deleteFlowWidget = Column(
children: [ children: [
@@ -230,35 +278,44 @@ void main() {
await tester.pump(); await tester.pump();
// Assert // Assert
final deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json'); final deleteFixture = FixtureReader.readAsMap(
'blog_post_create/delete_request.json',
);
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']); expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
expect(feedProbe.loadRequests, 1); expect(feedProbe.loadRequests, 1);
}); });
testWidgets('should handle error flow during blog post creation', (WidgetTester tester) async { testWidgets('should handle error flow during blog post creation', (
WidgetTester tester,
) async {
// Arrange // Arrange
blogPostRepository.createError = Exception('Network error'); blogPostRepository.createError = Exception('Network error');
Widget errorFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( Widget errorFlowWidget =
builder: (context, state) { BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateError) { builder: (context, state) {
return Column( if (state.errorMessage != null) {
children: [ return Column(
Text('Error: ${state.errorMessage}'), children: [
const SizedBox(height: 16), Text('Error: ${state.errorMessage}'),
ElevatedButton( const SizedBox(height: 16),
key: const Key('retryButton'), ElevatedButton(
onPressed: () async { key: const Key('retryButton'),
await blogPostCreateCubit.createBlogPost(title: 'Retry Title', videoUrl: null, description: 'Retry Description', image: null); onPressed: () async {
}, await blogPostCreateCubit.createBlogPost(
child: const Text('Повторить'), title: 'Retry Title',
), videoUrl: null,
], description: 'Retry Description',
); );
} },
return const Text('No Error'); child: const Text('Повторить'),
}, ),
); ],
);
}
return const Text('No Error');
},
);
// Act // Act
await tester.pumpWidget(createTestWidget(errorFlowWidget)); await tester.pumpWidget(createTestWidget(errorFlowWidget));
@@ -269,7 +326,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: null, videoUrl: null,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
await tester.pump(); await tester.pump();
@@ -278,18 +334,27 @@ void main() {
expect(find.byKey(const Key('retryButton')), findsOneWidget); expect(find.byKey(const Key('retryButton')), findsOneWidget);
}); });
testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async { testWidgets('should handle loading state during blog post creation', (
WidgetTester tester,
) async {
// Arrange // Arrange
blogPostRepository.createDelay = const Duration(milliseconds: 50); blogPostRepository.createDelay = const Duration(milliseconds: 50);
Widget loadingFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( Widget loadingFlowWidget =
builder: (context, state) { BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateLoading) { builder: (context, state) {
return const Column(children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Создание блог поста...')]); if (state.isLoading) {
} return const Column(
return const Text('Not Loading'); children: [
}, CircularProgressIndicator(),
); SizedBox(height: 16),
Text('Создание блог поста...'),
],
);
}
return const Text('Not Loading');
},
);
// Act // Act
await tester.pumpWidget(createTestWidget(loadingFlowWidget)); await tester.pumpWidget(createTestWidget(loadingFlowWidget));
@@ -300,7 +365,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: null, videoUrl: null,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
await tester.pump(); await tester.pump();
await tester.pump(); // Need to pump twice to get the loading state await tester.pump(); // Need to pump twice to get the loading state
@@ -314,29 +378,32 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
testWidgets('should handle success state after blog post creation', (WidgetTester tester) async { testWidgets('should handle success state after blog post creation', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget successFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>( Widget successFlowWidget =
listener: (context, state) { BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateLoaded) { listener: (context, state) {
feedProbe.loadFeedBlogPostList(); if (state.isSuccess) {
} feedProbe.loadFeedBlogPostList();
}, }
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( },
builder: (context, state) { child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateLoaded) { builder: (context, state) {
return const Column( if (state.isSuccess) {
children: [ return const Column(
Icon(Icons.check_circle, color: Colors.green, size: 48), children: [
SizedBox(height: 16), Icon(Icons.check_circle, color: Colors.green, size: 48),
Text('Блог пост успешно создан!'), SizedBox(height: 16),
], Text('Блог пост успешно создан!'),
); ],
} );
return const Text('Not Loaded'); }
}, return const Text('Not Loaded');
), },
); ),
);
// Act // Act
await tester.pumpWidget(createTestWidget(successFlowWidget)); await tester.pumpWidget(createTestWidget(successFlowWidget));
@@ -347,7 +414,6 @@ void main() {
title: fullCreateFixture['title'] as String?, title: fullCreateFixture['title'] as String?,
videoUrl: null, videoUrl: null,
description: fullCreateFixture['description_md'] as String?, description: fullCreateFixture['description_md'] as String?,
image: null,
); );
await tester.pump(); await tester.pump();
@@ -357,28 +423,43 @@ void main() {
expect(feedProbe.loadRequests, 1); expect(feedProbe.loadRequests, 1);
}); });
testWidgets('should handle form validation flow', (WidgetTester tester) async { testWidgets('should handle form validation flow', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget validationFlowWidget = Column( Widget validationFlowWidget = Column(
children: [ children: [
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField, key: AppKeys.blogPostCreateScreenTitleTextField,
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'), decoration: const InputDecoration(
labelText: 'Заголовок',
errorText: 'Заголовок обязателен',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenContentTextField, key: AppKeys.blogPostCreateScreenContentTextField,
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'), decoration: const InputDecoration(
labelText: 'Описание',
errorText: 'Описание обязательно',
),
maxLines: 6, maxLines: 6,
minLines: 4, minLines: 4,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField, key: AppKeys.blogPostCreateScreenVideoLinkTextField,
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку'), decoration: const InputDecoration(
labelText: 'Ссылка на видео',
errorText: 'Введите корректную ссылку',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () {},
child: const Text('Опубликовать'),
),
], ],
); );
@@ -415,7 +496,11 @@ void main() {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), Icon(
Icons.add_photo_alternate,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8), SizedBox(height: 8),
Text('Добавить изображение'), Text('Добавить изображение'),
], ],
@@ -431,7 +516,6 @@ void main() {
title: 'Test Title', title: 'Test Title',
videoUrl: null, videoUrl: null,
description: 'Test Description', description: 'Test Description',
image: null, // В реальном тесте здесь был бы XFile
); );
}, },
child: const Text('Опубликовать'), child: const Text('Опубликовать'),
@@ -450,90 +534,122 @@ void main() {
expect(imageUploadInitiated, isTrue); expect(imageUploadInitiated, isTrue);
}); });
testWidgets('should handle complete blog post creation with all features', (WidgetTester tester) async { testWidgets('should handle complete blog post creation with all features', (
WidgetTester tester,
) async {
// Arrange // Arrange
Widget completeFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>( Widget completeFlowWidget =
listener: (context, state) { BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
if (state is BlogPostCreateStateLoaded) { listener: (context, state) {
feedProbe.loadFeedBlogPostList(); if (state.isSuccess) {
} feedProbe.loadFeedBlogPostList();
}, }
child: Column( },
children: [ child: Column(
const Text('Заголовок'), children: [
const SizedBox(height: 8), const Text('Заголовок'),
TextFormField( const SizedBox(height: 8),
key: AppKeys.blogPostCreateScreenTitleTextField, TextFormField(
decoration: const InputDecoration(hintText: 'Введите заголовок поста'), key: AppKeys.blogPostCreateScreenTitleTextField,
), decoration: const InputDecoration(
const SizedBox(height: 16), hintText: 'Введите заголовок поста',
const Text('Описание'),
const SizedBox(height: 8),
TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4),
const SizedBox(height: 16),
const Text('Изображение поста'),
const SizedBox(height: 8),
GestureDetector(
key: AppKeys.imageWidget,
onTap: () {},
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text('Добавить изображение'),
],
), ),
), ),
), const SizedBox(height: 16),
const Text('Описание'),
const SizedBox(height: 8),
TextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
decoration: const InputDecoration(),
maxLines: 6,
minLines: 4,
),
const SizedBox(height: 16),
const Text('Изображение поста'),
const SizedBox(height: 8),
GestureDetector(
key: AppKeys.imageWidget,
onTap: () {},
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8),
Text('Добавить изображение'),
],
),
),
),
),
const SizedBox(height: 16),
const Text('Ссылка на видео'),
const SizedBox(height: 8),
TextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
decoration: const InputDecoration(),
),
const SizedBox(height: 32),
ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () async {
await blogPostCreateCubit.createBlogPost(
title: fullCreateFixture['title'] as String?,
videoUrl: fullCreateFixture['video_url'] as String?,
description:
fullCreateFixture['description_md'] as String?,
);
},
child: const Text('Опубликовать'),
),
],
), ),
const SizedBox(height: 16), );
const Text('Ссылка на видео'),
const SizedBox(height: 8),
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()),
const SizedBox(height: 32),
ElevatedButton(
key: AppKeys.blogPostCreateScreenCreateButton,
onPressed: () async {
await blogPostCreateCubit.createBlogPost(
title: fullCreateFixture['title'] as String?,
videoUrl: fullCreateFixture['video_url'] as String?,
description: fullCreateFixture['description_md'] as String?,
image: null,
);
},
child: const Text('Опубликовать'),
),
],
),
);
// Act // Act
await tester.pumpWidget(createTestWidget(completeFlowWidget)); await tester.pumpWidget(createTestWidget(completeFlowWidget));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); await tester.enterText(
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); fullCreateFixture['title'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
fullCreateFixture['description_md'] as String,
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
fullCreateFixture['video_url'] as String,
);
await tester.pump(); await tester.pump();
// Scroll to make sure the button is visible // Scroll to make sure the button is visible
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -200)); await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -200),
);
await tester.pump(); await tester.pump();
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
await tester.pump(); await tester.pump();
// Assert // Assert
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture); expect(
blogPostRepository.createPayloads.single.toJson(),
fullCreateFixture,
);
expect(feedProbe.loadRequests, 1); expect(feedProbe.loadRequests, 1);
}); });
}); });

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/blog_post_create/presentation/pages/blog_pos
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
import 'package:eatvio_app/features/feed/models/diets_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_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_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_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
@@ -19,14 +20,15 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mockito/annotations.dart'; import 'package:mocktail/mocktail.dart';
import 'package:mockito/mockito.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:network_image_mock/network_image_mock.dart'; import 'package:network_image_mock/network_image_mock.dart';
import 'blog_post_create_screen_test.mocks.dart'; class MockBlogPostRepository extends Mock implements BlogPostRepository {}
class MockFeedCubit extends Mock implements FeedCubit {}
@GenerateMocks([BlogPostRepository, BlogPostCreateCubit, FeedCubit])
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -37,13 +39,22 @@ void main() {
setUp(() async { setUp(() async {
await sl.reset(); await sl.reset();
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); dotenv.loadFromString(
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
);
AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests); AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests);
registerFallbackValue(<XFile>[]);
registerFallbackValue(<int>[]);
mockBlogPostRepository = MockBlogPostRepository(); mockBlogPostRepository = MockBlogPostRepository();
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: mockBlogPostRepository); blogPostCreateCubit = BlogPostCreateCubit(
blogPostRepository: mockBlogPostRepository,
);
mockFeedCubit = MockFeedCubit(); mockFeedCubit = MockFeedCubit();
when(mockFeedCubit.state).thenReturn(const FeedState.loading()); when(() => mockFeedCubit.state).thenReturn(const FeedState.loading());
when(mockFeedCubit.stream).thenAnswer((_) => Stream.value(const FeedState.loading())); when(
() => mockFeedCubit.stream,
).thenAnswer((_) => Stream.value(const FeedState.loading()));
when(() => mockFeedCubit.loadFeedBlogPostList()).thenAnswer((_) async {});
sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit); sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit);
sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit); sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit);
}); });
@@ -52,19 +63,25 @@ void main() {
blogPostCreateCubit.close(); blogPostCreateCubit.close();
}); });
Widget createTestWidget({FeedBlogPostModel? feedBlogPostModel, bool canPop = false}) { Widget createTestWidget({
FeedBlogPostModel? feedBlogPostModel,
bool canPop = false,
}) {
final router = GoRouter( final router = GoRouter(
initialLocation: '/blog-post-create', initialLocation: '/blog-post-create',
routes: [ routes: [
GoRoute( GoRoute(
path: '/home', path: '/home',
builder: (context, state) => const Scaffold(body: Center(child: Text('Home'))), builder: (context, state) =>
const Scaffold(body: Center(child: Text('Home'))),
), ),
GoRoute( GoRoute(
path: '/blog-post-create', path: '/blog-post-create',
builder: (context, state) => MultiBlocProvider( builder: (context, state) => MultiBlocProvider(
providers: [ providers: [
BlocProvider<BlogPostCreateCubit>.value(value: blogPostCreateCubit), BlocProvider<BlogPostCreateCubit>.value(
value: blogPostCreateCubit,
),
BlocProvider<FeedCubit>.value(value: mockFeedCubit), BlocProvider<FeedCubit>.value(value: mockFeedCubit),
], ],
child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel), child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel),
@@ -87,28 +104,46 @@ void main() {
); );
} }
testWidgets('displays all form fields when creating new blog post', (tester) async { testWidgets('displays all form fields when creating new blog post', (
tester,
) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Заголовок'), findsOneWidget); expect(find.text('Заголовок'), findsOneWidget);
expect(find.text('Описание'), findsOneWidget); expect(find.text('Описание'), findsOneWidget);
expect(find.text('Изображение поста'), findsOneWidget); expect(find.textContaining('Изображения'), findsOneWidget);
expect(find.text('Ссылка на видео'), findsOneWidget); expect(find.text('Ссылка на видео'), findsOneWidget);
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); expect(
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
findsOneWidget,
);
expect(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
findsOneWidget,
);
}); });
testWidgets('pre-fills form fields when editing existing blog post', (tester) async { testWidgets('pre-fills form fields when editing existing blog post', (
tester,
) async {
final existingPost = FeedBlogPostModel( final existingPost = FeedBlogPostModel(
id: 1, id: 1,
title: 'Test Title', title: 'Test Title',
description: 'Test Description', description: 'Test Description',
videoUrl: 'https://youtube.com/watch?v=test', videoUrl: 'https://rutube.ru/video/12345/',
image: '', // Changed: removed image so video field is visible image: '',
state: 'published', state: 'published',
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), author: const Author(
authorSlug: 'test-author',
authorName: 'Test Author',
authorProfileIcon: 'avatar.jpg',
authorIsMe: true,
),
createdAt: DateTime.now(), createdAt: DateTime.now(),
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
totalLikes: 0, totalLikes: 0,
@@ -117,24 +152,299 @@ void main() {
wasLikedByMe: false, wasLikedByMe: false,
); );
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); await tester.pumpWidget(
createTestWidget(feedBlogPostModel: existingPost),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final titleField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenTitleTextField)); final titleField = tester.widget<CustomTextFormField>(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
);
expect(titleField.controller?.text, 'Test Title'); expect(titleField.controller?.text, 'Test Title');
final descriptionField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenContentTextField)); final descriptionField = tester.widget<CustomTextFormField>(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
);
expect(descriptionField.controller?.text, 'Test Description'); expect(descriptionField.controller?.text, 'Test Description');
final videoFieldFinder = find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField); final videoField = tester.widget<CustomTextFormField>(
if (videoFieldFinder.evaluate().isNotEmpty) { find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
final videoField = tester.widget<CustomTextFormField>(videoFieldFinder); );
expect(videoField.controller?.text, 'https://youtube.com/watch?v=test'); expect(videoField.controller?.text, 'https://rutube.ru/video/12345/');
} expect(find.text('Сохранить'), findsOneWidget);
}); });
testWidgets('hides video field when image is present', (tester) async { testWidgets('shows video and gallery fields together', (tester) async {
await mockNetworkImagesFor(() async { await mockNetworkImagesFor(() async {
final existingPost = FeedBlogPostModel(
id: 1,
title: 'Test Title',
description: 'Test Description',
videoUrl: 'https://rutube.ru/video/12345/',
image: 'http://localhost/storage/test_image.jpg',
state: 'published',
author: const Author(
authorSlug: 'test-author',
authorName: 'Test Author',
authorProfileIcon: 'http://localhost/storage/avatar.jpg',
authorIsMe: true,
),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
totalLikes: 0,
totalViews: 0,
totalComments: 0,
wasLikedByMe: false,
images: const [
FeedGalleryImage(
id: 10,
url: 'http://localhost/storage/test_image.jpg',
),
],
);
await tester.pumpWidget(
createTestWidget(feedBlogPostModel: existingPost),
);
await tester.pumpAndSettle();
expect(find.text('Ссылка на видео'), findsOneWidget);
expect(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
findsOneWidget,
);
expect(find.textContaining('Изображения'), findsOneWidget);
});
});
testWidgets('allows entering text in title field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
'New Title',
);
await tester.pump();
final titleField = tester.widget<CustomTextFormField>(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
);
expect(titleField.controller?.text, 'New Title');
});
testWidgets('allows entering text in description field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
'New Description',
);
await tester.pump();
final descriptionField = tester.widget<CustomTextFormField>(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
);
expect(descriptionField.controller?.text, 'New Description');
});
testWidgets('allows entering text in video URL field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
'https://youtube.com/watch?v=new',
);
await tester.pump();
final videoField = tester.widget<CustomTextFormField>(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
);
expect(videoField.controller?.text, 'https://youtube.com/watch?v=new');
});
testWidgets('displays create button', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
expect(find.text('Создать'), findsOneWidget);
});
testWidgets('does not submit when form is empty', (tester) async {
when(
() => mockBlogPostRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
).thenAnswer((_) async {});
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
final createButton = find.text('Создать');
await tester.ensureVisible(createButton);
await tester.tap(createButton);
await tester.pumpAndSettle();
verifyNever(
() => mockBlogPostRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
);
expect(
find.text(
'Добавьте заголовок, описание, изображение или ссылку на видео',
),
findsOneWidget,
);
});
testWidgets('does not submit when video url is invalid', (tester) async {
when(
() => mockBlogPostRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
).thenAnswer((_) async {});
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
'Test Title',
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
'https://youtube.com/watch?v=abc',
);
await tester.pump();
final createButton = find.text('Создать');
await tester.ensureVisible(createButton);
await tester.tap(createButton);
await tester.pumpAndSettle();
verifyNever(
() => mockBlogPostRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
);
expect(
find.text('Поддерживаются только Rutube, VK и OK'),
findsOneWidget,
);
});
testWidgets('shows loading state when creating blog post', (tester) async {
when(
() => mockBlogPostRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
return;
});
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
'Test Title',
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
'Test Description',
);
await tester.pump();
final createButton = find.text('Создать');
await tester.ensureVisible(createButton);
await tester.tap(createButton);
await tester.pump();
expect(find.byType(Loader), findsWidgets);
await tester.pump(const Duration(milliseconds: 150));
await tester.pumpAndSettle();
});
testWidgets(
'calls FeedCubit.loadFeedBlogPostList when blog post is created successfully',
(tester) async {
when(
() => mockBlogPostRepository.createBlogPost(
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
images: any(named: 'images'),
),
).thenAnswer((_) async {
return;
});
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
final titleField = find.byKey(
AppKeys.blogPostCreateScreenTitleTextField,
);
final contentField = find.byKey(
AppKeys.blogPostCreateScreenContentTextField,
);
await tester.ensureVisible(titleField);
await tester.ensureVisible(contentField);
await tester.pumpAndSettle();
await tester.enterText(titleField, 'Test Title');
await tester.enterText(contentField, 'Test Description');
await tester.pump();
final createButton = find.text('Создать');
await tester.ensureVisible(createButton);
await tester.tap(createButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
verify(() => mockFeedCubit.loadFeedBlogPostList()).called(1);
},
);
testWidgets('does not submit update when video url is invalid', (
tester,
) async {
await mockNetworkImagesFor(() async {
when(
() => mockBlogPostRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
).thenAnswer((_) async {});
final existingPost = FeedBlogPostModel( final existingPost = FeedBlogPostModel(
id: 1, id: 1,
title: 'Test Title', title: 'Test Title',
@@ -154,136 +464,140 @@ void main() {
totalViews: 0, totalViews: 0,
totalComments: 0, totalComments: 0,
wasLikedByMe: false, wasLikedByMe: false,
images: const [
FeedGalleryImage(
id: 10,
url: 'http://localhost/storage/test_image.jpg',
),
],
); );
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); await tester.pumpWidget(
createTestWidget(feedBlogPostModel: existingPost),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Ссылка на видео'), findsNothing); await tester.enterText(
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsNothing); find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
'https://youtube.com/watch?v=abc',
);
await tester.pump();
final saveButton = find.text('Сохранить');
await tester.ensureVisible(saveButton);
await tester.tap(saveButton);
await tester.pumpAndSettle();
verifyNever(
() => mockBlogPostRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
);
expect(
find.text('Поддерживаются только Rutube, VK и OK'),
findsOneWidget,
);
}); });
}); });
testWidgets('hides image field when video URL is present', (tester) async { testWidgets('does not submit update when form content is cleared', (
final existingPost = FeedBlogPostModel( tester,
id: 1, ) async {
title: 'Test Title',
description: 'Test Description',
videoUrl: 'https://youtube.com/watch?v=test',
image: '',
state: 'published',
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
totalLikes: 0,
totalViews: 0,
totalComments: 0,
wasLikedByMe: false,
);
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
await tester.pumpAndSettle();
expect(find.text('Изображение поста'), findsNothing);
});
testWidgets('allows entering text in title field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'New Title');
await tester.pump();
final titleField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenTitleTextField));
expect(titleField.controller?.text, 'New Title');
});
testWidgets('allows entering text in description field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'New Description');
await tester.pump();
final descriptionField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenContentTextField));
expect(descriptionField.controller?.text, 'New Description');
});
testWidgets('allows entering text in video URL field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), 'https://youtube.com/watch?v=new');
await tester.pump();
final videoField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField));
expect(videoField.controller?.text, 'https://youtube.com/watch?v=new');
});
testWidgets('displays create button', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
expect(find.text('Создать'), findsOneWidget);
});
testWidgets('shows loading state when creating blog post', (tester) async {
when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
return;
});
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title');
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'Test Description');
await tester.pump();
final createButton = find.text('Создать');
await tester.ensureVisible(createButton);
await tester.tap(createButton);
await tester.pump();
expect(find.byType(Loader), findsWidgets);
await tester.pump(const Duration(milliseconds: 150));
await tester.pumpAndSettle();
});
testWidgets('calls FeedCubit.loadFeedBlogPostList when blog post is created successfully', (tester) async {
when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async {
return;
});
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField);
final contentField = find.byKey(AppKeys.blogPostCreateScreenContentTextField);
await tester.ensureVisible(titleField);
await tester.ensureVisible(contentField);
await tester.pumpAndSettle();
await tester.enterText(titleField, 'Test Title');
await tester.enterText(contentField, 'Test Description');
await tester.pump();
final createButton = find.text('Создать');
await tester.ensureVisible(createButton);
await tester.tap(createButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
verify(mockFeedCubit.loadFeedBlogPostList()).called(1);
});
testWidgets('calls updateBlogPost when editing existing post', (tester) async {
await mockNetworkImagesFor(() async { await mockNetworkImagesFor(() async {
when(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).thenAnswer((_) async { when(
() => mockBlogPostRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
).thenAnswer((_) async {});
final existingPost = FeedBlogPostModel(
id: 1,
title: 'Test Title',
description: 'Test Description',
videoUrl: '',
image: '',
state: 'published',
author: const Author(
authorSlug: 'test-author',
authorName: 'Test Author',
authorProfileIcon: 'http://localhost/storage/avatar.jpg',
authorIsMe: true,
),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
totalLikes: 0,
totalViews: 0,
totalComments: 0,
wasLikedByMe: false,
);
await tester.pumpWidget(
createTestWidget(feedBlogPostModel: existingPost),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
'',
);
await tester.enterText(
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
'',
);
await tester.pump();
final saveButton = find.text('Сохранить');
await tester.ensureVisible(saveButton);
await tester.tap(saveButton);
await tester.pumpAndSettle();
verifyNever(
() => mockBlogPostRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
);
expect(
find.text(
'Добавьте заголовок, описание, изображение или ссылку на видео',
),
findsOneWidget,
);
});
});
testWidgets('calls updateBlogPost when editing existing post', (
tester,
) async {
await mockNetworkImagesFor(() async {
when(
() => mockBlogPostRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
).thenAnswer((_) async {
return; return;
}); });
@@ -306,26 +620,46 @@ void main() {
totalViews: 0, totalViews: 0,
totalComments: 0, totalComments: 0,
wasLikedByMe: false, wasLikedByMe: false,
images: const [
FeedGalleryImage(
id: 10,
url: 'http://localhost/storage/test_image.jpg',
),
],
); );
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost, canPop: true)); await tester.pumpWidget(
createTestWidget(feedBlogPostModel: existingPost, canPop: true),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField); final titleField = find.byKey(
AppKeys.blogPostCreateScreenTitleTextField,
);
expect(titleField, findsOneWidget); expect(titleField, findsOneWidget);
await tester.enterText(titleField, 'Updated Title'); await tester.enterText(titleField, 'Updated Title');
await tester.pump(); await tester.pump();
final createButton = find.text('Создать'); final saveButton = find.text('Сохранить');
await tester.ensureVisible(createButton); await tester.ensureVisible(saveButton);
await tester.tap(createButton, warnIfMissed: false); await tester.tap(saveButton, warnIfMissed: false);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
verify(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).called(1); verify(
() => mockBlogPostRepository.updateBlogPost(
id: any(named: 'id'),
title: any(named: 'title'),
videoUrl: any(named: 'videoUrl'),
description: any(named: 'description'),
existingMediaIds: any(named: 'existingMediaIds'),
newImages: any(named: 'newImages'),
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
),
).called(1);
}); });
}); });
@@ -344,8 +678,9 @@ void main() {
}); });
testWidgets('renders error state', (tester) async { testWidgets('renders error state', (tester) async {
blogPostCreateCubit.emit(
blogPostCreateCubit.emit(const BlogPostCreateState.error(errorMessage: 'Error creating post')); const BlogPostCreateState(errorMessage: 'Error creating post'),
);
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -354,7 +689,7 @@ void main() {
}); });
testWidgets('renders loading state', (tester) async { testWidgets('renders loading state', (tester) async {
blogPostCreateCubit.emit(const BlogPostCreateState.loading()); blogPostCreateCubit.emit(const BlogPostCreateState(isLoading: true));
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@@ -25,204 +25,172 @@ void main() {
test('deleteBlogPost вызывает сервис', () async { test('deleteBlogPost вызывает сервис', () async {
const id = 1; const id = 1;
when(() => mockService.deleteBlogPost(id: id)).thenAnswer((_) async => {}); when(
() => mockService.deleteBlogPost(id: id),
).thenAnswer((_) async => {});
await repository.deleteBlogPost(id); await repository.deleteBlogPost(id);
verify(() => mockService.deleteBlogPost(id: id)).called(1); verify(() => mockService.deleteBlogPost(id: id)).called(1);
}); });
test('deleteBlogPostMedia вызывает сервис', () async {
when(
() => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20),
).thenAnswer((_) async => {});
await repository.deleteBlogPostMedia(postId: 10, mediaId: 20);
verify(
() => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20),
).called(1);
});
test('createBlogPost вызывает сервис с FormData', () async { test('createBlogPost вызывает сервис с FormData', () async {
const title = 'Test Title';
const description = 'Test Description';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(title, null, description, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('updateBlogPost вызывает сервис с FormData', () async {
const id = 1;
const title = 'Updated Title';
when( when(
() => mockService.updateBlogPost( () => mockService.createBlogPost(formData: any(named: 'formData')),
id: id,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {}); ).thenAnswer((_) async => {});
await repository.updateBlogPost(id, title, null, null, null, null); await repository.createBlogPost(
title: 'Test Title',
description: 'Test Description',
);
verify( final captured =
() => mockService.updateBlogPost( verify(
id: id, () => mockService.createBlogPost(
formData: any(named: 'formData'), formData: captureAny(named: 'formData'),
), ),
).called(1); ).captured.single
}); as FormData;
test('createBlogPost - обрабатывает пустые поля', () async { expect(
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); captured.fields.map((e) => '${e.key}=${e.value}'),
containsAll(['title=Test Title', 'description_md=Test Description']),
await repository.createBlogPost(null, null, null, null); );
expect(captured.files, isEmpty);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('createBlogPost - обрабатывает только title', () async {
const title = 'Test Title';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(title, null, null, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
}); });
test('createBlogPost - обрабатывает только videoUrl', () async { test('createBlogPost - обрабатывает только videoUrl', () async {
const videoUrl = 'https://example.com/video.mp4'; const videoUrl = 'https://rutube.ru/video/12345/';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(null, videoUrl, null, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('createBlogPost - обрабатывает только description', () async {
const description = 'Test Description';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(null, null, description, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('updateBlogPost - обрабатывает все поля', () async {
const id = 1;
const title = 'Updated Title';
const videoUrl = 'https://example.com/video.mp4';
const description = 'Updated Description';
when( when(
() => mockService.updateBlogPost( () => mockService.createBlogPost(formData: any(named: 'formData')),
id: id,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {}); ).thenAnswer((_) async => {});
await repository.updateBlogPost(id, title, videoUrl, description, null, null); await repository.createBlogPost(videoUrl: videoUrl);
verify( final captured =
() => mockService.updateBlogPost( verify(
id: id, () => mockService.createBlogPost(
formData: any(named: 'formData'), formData: captureAny(named: 'formData'),
), ),
).called(1); ).captured.single
as FormData;
expect(
captured.fields.map((e) => '${e.key}=${e.value}'),
contains('video_url=$videoUrl'),
);
}); });
test(
'updateBlogPost отправляет existing_media_ids и удаляет media пачкой',
() async {
when(
() => mockService.deleteBlogPostMedia(
postId: 1,
mediaId: any(named: 'mediaId'),
),
).thenAnswer((_) async => {});
when(
() => mockService.updateBlogPost(
id: 1,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {});
await repository.updateBlogPost(
id: 1,
title: 'Updated Title',
existingMediaIds: [5, 3],
mediaIdsToDelete: [9, 8],
);
verify(
() => mockService.deleteBlogPostMedia(postId: 1, mediaId: 9),
).called(1);
verify(
() => mockService.deleteBlogPostMedia(postId: 1, mediaId: 8),
).called(1);
final captured =
verify(
() => mockService.updateBlogPost(
id: 1,
formData: captureAny(named: 'formData'),
),
).captured.single
as FormData;
final fields = captured.fields
.map((e) => '${e.key}=${e.value}')
.toList();
expect(fields, contains('title=Updated Title'));
expect(fields, contains('description_md='));
expect(fields, contains('video_url='));
expect(fields, contains('existing_media_ids[]=5'));
expect(fields, contains('existing_media_ids[]=3'));
},
);
test(
'updateBlogPost отправляет пустые text fields для очистки на бэке',
() async {
when(
() => mockService.updateBlogPost(
id: 1,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {});
await repository.updateBlogPost(
id: 1,
title: '',
description: '',
videoUrl: '',
existingMediaIds: const [],
);
final captured =
verify(
() => mockService.updateBlogPost(
id: 1,
formData: captureAny(named: 'formData'),
),
).captured.single
as FormData;
final fields = captured.fields
.map((e) => '${e.key}=${e.value}')
.toList();
expect(fields, contains('title='));
expect(fields, contains('description_md='));
expect(fields, contains('video_url='));
},
);
test('updateBlogPost - обрабатывает пустые поля', () async { test('updateBlogPost - обрабатывает пустые поля', () async {
const id = 1;
when( when(
() => mockService.updateBlogPost( () =>
id: id, mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {}); ).thenAnswer((_) async => {});
await repository.updateBlogPost(id, null, null, null, null, null); await repository.updateBlogPost(id: 1);
verify( verify(
() => mockService.updateBlogPost( () =>
id: id, mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
formData: any(named: 'formData'),
),
).called(1);
});
test('createBlogPost - обрабатывает пустые поля', () async {
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(null, null, null, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('createBlogPost - обрабатывает только title', () async {
const title = 'Test Title';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(title, null, null, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('createBlogPost - обрабатывает только videoUrl', () async {
const videoUrl = 'https://example.com/video.mp4';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(null, videoUrl, null, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('createBlogPost - обрабатывает только description', () async {
const description = 'Test Description';
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
await repository.createBlogPost(null, null, description, null);
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
});
test('updateBlogPost - обрабатывает все поля', () async {
const id = 1;
const title = 'Updated Title';
const videoUrl = 'https://example.com/video.mp4';
const description = 'Updated Description';
when(
() => mockService.updateBlogPost(
id: id,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {});
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
verify(
() => mockService.updateBlogPost(
id: id,
formData: any(named: 'formData'),
),
).called(1);
});
test('updateBlogPost - обрабатывает пустые поля', () async {
const id = 1;
when(
() => mockService.updateBlogPost(
id: id,
formData: any(named: 'formData'),
),
).thenAnswer((_) async => {});
await repository.updateBlogPost(id, null, null, null, null, null);
verify(
() => mockService.updateBlogPost(
id: id,
formData: any(named: 'formData'),
),
).called(1); ).called(1);
}); });
}); });

View File

@@ -18,8 +18,9 @@ void main() {
test('createBlogPost - создает пост блога', () async { test('createBlogPost - создает пост блога', () async {
final formData = FormData.fromMap({}); final formData = FormData.fromMap({});
when(mockBlogPostService.createBlogPost(formData: formData)) when(
.thenAnswer((_) async => Future.value()); mockBlogPostService.createBlogPost(formData: formData),
).thenAnswer((_) async => Future.value());
await mockBlogPostService.createBlogPost(formData: formData); await mockBlogPostService.createBlogPost(formData: formData);
@@ -30,19 +31,35 @@ void main() {
const id = 1; const id = 1;
final formData = FormData.fromMap({}); final formData = FormData.fromMap({});
when(mockBlogPostService.updateBlogPost(id: id, formData: formData)) when(
.thenAnswer((_) async => Future.value()); mockBlogPostService.updateBlogPost(id: id, formData: formData),
).thenAnswer((_) async => Future.value());
await mockBlogPostService.updateBlogPost(id: id, formData: formData); await mockBlogPostService.updateBlogPost(id: id, formData: formData);
verify(mockBlogPostService.updateBlogPost(id: id, formData: formData)).called(1); verify(
mockBlogPostService.updateBlogPost(id: id, formData: formData),
).called(1);
});
test('deleteBlogPostMedia - удаляет изображение поста', () async {
when(
mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2),
).thenAnswer((_) async => Future.value());
await mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2);
verify(
mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2),
).called(1);
}); });
test('deleteBlogPost - удаляет пост блога', () async { test('deleteBlogPost - удаляет пост блога', () async {
const id = 1; const id = 1;
when(mockBlogPostService.deleteBlogPost(id: id)) when(
.thenAnswer((_) async => Future.value()); mockBlogPostService.deleteBlogPost(id: id),
).thenAnswer((_) async => Future.value());
await mockBlogPostService.deleteBlogPost(id: id); await mockBlogPostService.deleteBlogPost(id: id);
@@ -50,4 +67,3 @@ void main() {
}); });
}); });
} }

View File

@@ -0,0 +1,191 @@
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('BlogPostFormValidator.title', () {
test('allows empty', () {
expect(BlogPostFormValidator.title(''), isNull);
expect(BlogPostFormValidator.title(' '), isNull);
expect(BlogPostFormValidator.title(null), isNull);
});
test('rejects shorter than 3 chars', () {
expect(
BlogPostFormValidator.title('ab'),
BlogPostFormValidator.titleTooShort,
);
expect(
BlogPostFormValidator.title(' a '),
BlogPostFormValidator.titleTooShort,
);
});
test('allows 3+ chars', () {
expect(BlogPostFormValidator.title('abc'), isNull);
expect(BlogPostFormValidator.title(' hello '), isNull);
});
});
group('BlogPostFormValidator.description', () {
test('allows empty and rejects short', () {
expect(BlogPostFormValidator.description(''), isNull);
expect(
BlogPostFormValidator.description('xy'),
BlogPostFormValidator.descriptionTooShort,
);
expect(BlogPostFormValidator.description('xyz'), isNull);
});
});
group('BlogPostFormValidator.videoUrl', () {
test('allows empty', () {
expect(BlogPostFormValidator.videoUrl(''), isNull);
expect(BlogPostFormValidator.videoUrl(' '), isNull);
});
test('rejects unsupported hosts', () {
expect(
BlogPostFormValidator.videoUrl('https://youtube.com/watch?v=abc'),
BlogPostFormValidator.videoUrlUnsupportedHost,
);
expect(
BlogPostFormValidator.videoUrl('https://rutube.ru.evil.com/video/abc'),
BlogPostFormValidator.videoUrlUnsupportedHost,
);
});
test('accepts rutube / vk / ok formats', () {
expect(
BlogPostFormValidator.videoUrl('https://rutube.ru/video/abc123/'),
isNull,
);
expect(
BlogPostFormValidator.videoUrl('https://www.rutube.ru/shorts/abc123'),
isNull,
);
expect(
BlogPostFormValidator.videoUrl('https://vk.com/video-123_456'),
isNull,
);
expect(
BlogPostFormValidator.videoUrl('https://vkvideo.ru/video123_456/'),
isNull,
);
expect(
BlogPostFormValidator.videoUrl('https://ok.ru/video/12345'),
isNull,
);
});
test('rejects invalid path formats for allowed hosts', () {
expect(
BlogPostFormValidator.videoUrl('https://rutube.ru/channel/abc'),
BlogPostFormValidator.videoUrlRutubeFormat,
);
expect(
BlogPostFormValidator.videoUrl('https://vk.com/feed'),
BlogPostFormValidator.videoUrlVkFormat,
);
expect(
BlogPostFormValidator.videoUrl('https://ok.ru/profile/1'),
BlogPostFormValidator.videoUrlOkFormat,
);
});
test('rejects malformed urls', () {
expect(
BlogPostFormValidator.videoUrl('not-a-url'),
BlogPostFormValidator.videoUrlInvalid,
);
expect(
BlogPostFormValidator.videoUrl('ftp://rutube.ru/video/abc'),
BlogPostFormValidator.videoUrlInvalid,
);
});
});
group('BlogPostFormValidator.contentRequiredMessage', () {
test('requires at least one filled field', () {
expect(
BlogPostFormValidator.contentRequiredMessage(
title: '',
description: '',
videoUrl: '',
imageCount: 0,
),
BlogPostFormValidator.contentRequired,
);
});
test('accepts any valid content source', () {
expect(
BlogPostFormValidator.contentRequiredMessage(
title: 'abc',
description: '',
videoUrl: '',
imageCount: 0,
),
isNull,
);
expect(
BlogPostFormValidator.contentRequiredMessage(
title: '',
description: 'abc',
videoUrl: '',
imageCount: 0,
),
isNull,
);
expect(
BlogPostFormValidator.contentRequiredMessage(
title: '',
description: '',
videoUrl: '',
imageCount: 1,
),
isNull,
);
expect(
BlogPostFormValidator.contentRequiredMessage(
title: '',
description: '',
videoUrl: 'https://rutube.ru/video/abc123/',
imageCount: 0,
),
isNull,
);
});
test('does not count short title or invalid video as content', () {
expect(
BlogPostFormValidator.contentRequiredMessage(
title: 'ab',
description: '',
videoUrl: 'https://youtube.com/watch?v=1',
imageCount: 0,
),
BlogPostFormValidator.contentRequired,
);
});
});
group('BlogPostFormValidator.imagePaths', () {
test('allows jpg/jpeg/png', () {
expect(
BlogPostFormValidator.imagePaths(['a.jpg', 'b.JPEG', 'c.png']),
isNull,
);
});
test('rejects other extensions', () {
expect(
BlogPostFormValidator.imagePaths(['a.webp']),
BlogPostFormValidator.imageTypeInvalid,
);
expect(
BlogPostFormValidator.imagePaths(['noext']),
BlogPostFormValidator.imageTypeInvalid,
);
});
});
}

View File

@@ -33,4 +33,3 @@ void main() {
}); });
}); });
} }

View File

@@ -100,4 +100,3 @@ void main() {
}); });
}); });
} }

View File

@@ -63,7 +63,10 @@ void main() {
}); });
test('toJson serializes all fields correctly', () { 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(); final json = model.toJson();
@@ -105,8 +108,21 @@ void main() {
test('toJson serializes all fields correctly', () { test('toJson serializes all fields correctly', () {
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); const certificates = CertificatesModel(
const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]); 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(); final json = model.toJson();
@@ -114,6 +130,38 @@ void main() {
expect(json['social_links'], isNotNull); expect(json['social_links'], isNotNull);
expect(json['cert_photos'], ['photo1.jpg']); expect(json['cert_photos'], ['photo1.jpg']);
expect(json['completed_courses'], isNotNull); 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);
expect(
valid.copyWith(birthDate: '2020-02-31').hasValidPassportData,
isFalse,
);
}); });
}); });
} }

View File

@@ -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/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -46,7 +47,11 @@ void main() {
}); });
test('fromJson handles null additional field', () { 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); final model = CanceledModerationModel.fromJson(json);
@@ -56,7 +61,11 @@ void main() {
}); });
test('toJson serializes all fields correctly', () { 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(); final json = model.toJson();
@@ -122,10 +131,67 @@ void main() {
expect(model.canceledModeration!.status, ModerationStatus.canceled); 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', () { test('toJson serializes ConsultationRequestModel correctly', () {
const educationModel = EducationModel(requestState: ModerationStatus.approved); const educationModel = EducationModel(
const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; requestState: ModerationStatus.approved,
const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null); );
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(); final json = model.toJson();

View File

@@ -12,7 +12,7 @@ void main() {
acceptOffer: false, acceptOffer: false,
loading: false, loading: false,
requestModel: null, requestModel: null,
isUpdating: false isUpdating: false,
); );
expect(state.consultant, consultant); expect(state.consultant, consultant);
@@ -20,6 +20,8 @@ void main() {
expect(state.loading, false); expect(state.loading, false);
expect(state.requestModel, isNull); expect(state.requestModel, isNull);
expect(state.certPhotos, isEmpty); expect(state.certPhotos, isEmpty);
expect(state.awaitingScoringOutcome, isFalse);
expect(state.fieldErrors, isEmpty);
}); });
test('creates instance with custom values', () { test('creates instance with custom values', () {
@@ -38,7 +40,7 @@ void main() {
loading: true, loading: true,
requestModel: null, requestModel: null,
certPhotos: [certPhoto], certPhotos: [certPhoto],
isUpdating: false isUpdating: false,
); );
expect(state.acceptOffer, true); expect(state.acceptOffer, true);
@@ -54,7 +56,7 @@ void main() {
acceptOffer: false, acceptOffer: false,
loading: false, loading: false,
requestModel: null, requestModel: null,
isUpdating: false isUpdating: false,
); );
final updatedState = originalState.copyWith( final updatedState = originalState.copyWith(
@@ -92,7 +94,7 @@ void main() {
loading: false, loading: false,
requestModel: null, requestModel: null,
certPhotos: [certPhoto1], certPhotos: [certPhoto1],
isUpdating: false isUpdating: false,
); );
final updatedState = originalState.copyWith( final updatedState = originalState.copyWith(
@@ -100,9 +102,14 @@ void main() {
); );
expect(updatedState.certPhotos.length, 2); expect(updatedState.certPhotos.length, 2);
expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg'); expect(
expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg'); updatedState.certPhotos.first.url,
'https://example.com/photo1.jpg',
);
expect(
updatedState.certPhotos.last.url,
'https://example.com/photo2.jpg',
);
}); });
}); });
} }

View File

@@ -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_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/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/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/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -23,16 +26,50 @@ void main() {
late RegisterConsultantCubit cubit; late RegisterConsultantCubit cubit;
late ConsultationRequestModel requestModel; late ConsultationRequestModel requestModel;
late List<ConsultantCertPhotoModel> certPhotosFromFixture; late List<ConsultantCertPhotoModel> certPhotosFromFixture;
late List<SbpBankModel> sbpBanksFromFixture;
late ConsultantProfileModel consultantProfile; 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(() { setUp(() {
repository = MockConsultantRepository(); repository = MockConsultantRepository();
cubit = RegisterConsultantCubit(consultantRepository: repository); cubit = RegisterConsultantCubit(
requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); consultantRepository: repository,
certPhotosFromFixture = FixtureReader.readAsList( scoringPollInterval: const Duration(milliseconds: 30),
'consultant/consultant_cert_photos.json', );
).map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>)).toList(); requestModel = ConsultationRequestModel.fromJson(
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); 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 { tearDown(() async {
@@ -44,11 +81,16 @@ void main() {
expect(cubit.state.consultant.hasMedicalEducation, isTrue); expect(cubit.state.consultant.hasMedicalEducation, isTrue);
expect(cubit.state.acceptOffer, isFalse); expect(cubit.state.acceptOffer, isFalse);
expect(cubit.state.requestModel, isNull); expect(cubit.state.requestModel, isNull);
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none);
}); });
test('getMyRequestStatus hydrates state with backend data', () async { test('getMyRequestStatus hydrates state with backend data', () async {
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel); when(
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); () => repository.getMyRequestStatus(any<String>()),
).thenAnswer((_) async => requestModel);
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.getMyRequestStatus('my-consultant-slug');
@@ -56,53 +98,308 @@ void main() {
expect(state.loading, isFalse); expect(state.loading, isFalse);
expect(state.requestModel, equals(requestModel)); expect(state.requestModel, equals(requestModel));
expect(state.consultant.certificates, isNotNull); 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!.length, greaterThan(0));
expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); 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'); 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, hasLength(3));
expect(state.certPhotos.map((e) => e.id), containsAll(<int>[101, 102, 103])); expect(
verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); 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.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 { 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(); await cubit.disableLoading();
expect(cubit.state.loading, isFalse); final results = cubit.searchSbpBanks('сбер');
// disableLoading now keeps all photos from repository.
expect(cubit.state.certPhotos, hasLength(3)); expect(results, hasLength(1));
verify(() => repository.getConsultantCertPhotos()).called(1); expect(results.first.bankMemberId, '100000000111');
}); });
test('updateConsultantProfileModel replaces consultant data and offer flag', () { test('findSbpBankById returns matching bank', () async {
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( when(
() => repository.registerConsultant( () => repository.getConsultantCertPhotos(),
consultant: any<ConsultantProfileModel>(named: 'consultant'), ).thenAnswer((_) async => certPhotosFromFixture);
update: any<bool>(named: 'update'), await cubit.disableLoading();
),
).thenAnswer((_) async {});
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 { test(
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel); 'updateConsultantProfileModel replaces consultant data and offer flag',
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); () {
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( when(
() => repository.registerConsultant( () => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'), consultant: any<ConsultantProfileModel>(named: 'consultant'),
@@ -113,21 +410,170 @@ void main() {
await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.getMyRequestStatus('my-consultant-slug');
await cubit.registerConsultant(); 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(
'retryScoring writes fieldErrors when passport data is invalid',
() async {
cubit.emit(
cubit.state.copyWith(
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
loading: false,
),
);
await cubit.retryScoring();
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
verifyNever(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(named: 'update'),
),
);
},
);
test('registerConsultant writes fieldErrors without calling API', () async {
when(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(named: 'update'),
),
).thenAnswer((_) async {});
await cubit.registerConsultant();
expect(cubit.state.fieldErrors['last_name'], 'Укажите фамилию');
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
expect(cubit.state.fieldErrors['birth_date'], 'Укажите дату рождения');
expect(cubit.state.error, isNull);
verifyNever(
() => repository.registerConsultant(
consultant: any<ConsultantProfileModel>(named: 'consultant'),
update: any<bool>(named: 'update'),
),
);
});
test('editing a field clears only that field error', () async {
await cubit.registerConsultant();
expect(cubit.state.fieldErrors.containsKey('last_name'), isTrue);
expect(cubit.state.fieldErrors.containsKey('inn'), isTrue);
cubit.updateConsultantProfileModel(
cubit.state.consultant.copyWith(lastName: 'Иванов'),
);
expect(cubit.state.fieldErrors.containsKey('last_name'), isFalse);
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
});
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 { test('removeCertPhoto marks cert as deleted locally', () async {
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel); when(
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); () => repository.getMyRequestStatus(any<String>()),
when(() => repository.hideConsultantCertPhoto(any<int>())).thenAnswer((_) async {}); ).thenAnswer((_) async => requestModel);
when(
() => repository.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotosFromFixture);
when(
() => repository.hideConsultantCertPhoto(any<int>()),
).thenAnswer((_) async {});
await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.getMyRequestStatus('my-consultant-slug');
// All photos from backend are present in state.
expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.certPhotos, hasLength(3));
await cubit.removeCertPhoto(101, null); await cubit.removeCertPhoto(101, null);
// Current implementation only tracks deleted IDs; photos list remains unchanged here.
verifyNever(() => repository.hideConsultantCertPhoto(any<int>())); verifyNever(() => repository.hideConsultantCertPhoto(any<int>()));
expect(cubit.state.deletedPhotoIds, contains(101)); expect(cubit.state.deletedPhotoIds, contains(101));
expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.certPhotos, hasLength(3));
@@ -136,7 +582,9 @@ void main() {
test('uploadCertPhotos - не загружает если список пуст', () async { test('uploadCertPhotos - не загружает если список пуст', () async {
await cubit.addCertPhotos([]); await cubit.addCertPhotos([]);
verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); verifyNever(
() => repository.storeConsultantCertPhotos(files: any(named: 'files')),
);
}); });
test('updateConsultantProfileModel - обновляет без acceptOffer', () { test('updateConsultantProfileModel - обновляет без acceptOffer', () {
@@ -147,4 +595,38 @@ void main() {
expect(cubit.state.loading, isFalse); 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);
});
});
} }

View File

@@ -18,15 +18,28 @@ void main() {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold( 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 { testWidgets('renders additional settings screen', (tester) async {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); const consultant = ConsultantProfileModel(
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));

View File

@@ -21,12 +21,18 @@ import 'certificates_and_diplomas_screen_smoke_test.mocks.dart';
void main() { void main() {
group('CertificatesAndDiplomasScreen smoke', () { group('CertificatesAndDiplomasScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); 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) { Widget wrap(Widget child) {
@@ -45,15 +51,30 @@ void main() {
testWidgets('renders certificates and diplomas screen', (tester) async { testWidgets('renders certificates and diplomas screen', (tester) async {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); const consultant = ConsultantProfileModel(
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); 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();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
@@ -67,16 +88,28 @@ void main() {
hasMedicalEducation: null, hasMedicalEducation: null,
socialLinks: null, socialLinks: null,
photos: 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); 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();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));

View File

@@ -19,43 +19,88 @@ import 'last_step_screen_test.mocks.dart';
void main() { void main() {
group('LastStepScreen smoke', () { group('LastStepScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); 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) { Widget wrap(Widget child, RegisterConsultantCubit cubit) {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold( 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(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel();
hasMedicalEducation: null, const state = RegisterConsultantState(
socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), consultant: consultant,
photos: null, acceptOffer: false,
certificates: null, loading: false,
isUpdating: true,
); );
const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false);
when(cubit.state).thenReturn(state); when(cubit.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any, any)).thenReturn(null);
await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); await tester.pumpWidget(wrap(const LastStepScreen(), cubit));
await tester.pump(); await tester.pump();
expect(find.byType(LastStepScreen), findsOneWidget); expect(find.byType(CheckboxListTile), findsOneWidget);
expect(find.text('Последний шаг'), findsOneWidget); expect(find.textContaining('ФРДО'), findsNothing);
}); });
}); });
} }

View File

@@ -0,0 +1,79 @@
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:eatvio_app/features/create_diet/presentation/widgets/error_widget.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);
});
testWidgets('shows ValidationErrorWidget for invalid field', (
tester,
) async {
cubit.emit(
cubit.state.copyWith(fieldErrors: const {'inn': 'Некорректный ИНН'}),
);
await tester.pumpWidget(wrap(const PassportDataScreen()));
await tester.pump();
expect(find.byType(ValidationErrorWidget), findsOneWidget);
expect(find.text('Некорректный ИНН'), findsOneWidget);
});
});
}

View File

@@ -19,12 +19,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart';
void main() { void main() {
group('PhotoOrScansScreen smoke', () { group('PhotoOrScansScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); 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) { Widget wrap(Widget child, RegisterConsultantCubit cubit) {
@@ -38,7 +44,10 @@ void main() {
], ],
supportedLocales: const [Locale('ru')], supportedLocales: const [Locale('ru')],
home: Scaffold( 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); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -61,7 +81,10 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PhotoOrScansScreen), findsOneWidget); expect(find.byType(PhotoOrScansScreen), findsOneWidget);
expect(find.text('Загрузите фото или сканы дипломов и сертификатов'), findsOneWidget); expect(
find.text('Загрузите фото или сканы дипломов и сертификатов'),
findsOneWidget,
);
}); });
}); });
@@ -71,11 +94,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);

View File

@@ -1,24 +1,59 @@
import 'package:eatvio_app/core/theme/theme_helper.dart'; 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/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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockConsultantRepository extends Mock implements ConsultantRepository {}
void main() { void main() {
group('RegistrationAsScreen smoke', () { 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) { Widget wrap(Widget child) {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
home: Scaffold(body: child), home: Scaffold(
body: BlocProvider<RegisterConsultantCubit>.value(
value: cubit,
child: child,
),
),
); );
} }
testWidgets('renders registration as screen', (tester) async { testWidgets('renders registration as screen', (tester) async {
await tester.pumpWidget(wrap(const RegistrationAsScreen())); await tester.pumpWidget(wrap(const RegistrationAsScreen()));
await tester.pump(); await tester.pump();
await tester.pump();
expect(find.byType(RegistrationAsScreen), findsOneWidget); expect(find.byType(RegistrationAsScreen), findsOneWidget);
expect(find.text('Регистрация в качестве'), findsOneWidget); expect(find.text('Регистрация в качестве'), findsOneWidget);
expect(find.text('Физического лица'), findsOneWidget); expect(find.text('Физического лица'), findsOneWidget);
expect(cubit.state.consultant.legalType, 'individualPerson');
}); });
}); });
} }

View File

@@ -21,12 +21,18 @@ import 'social_networks_screen_smoke_test.mocks.dart';
void main() { void main() {
group('SocialNetworksScreen smoke', () { group('SocialNetworksScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); 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) { Widget wrap(Widget child) {
@@ -47,18 +53,34 @@ void main() {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); 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();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
@@ -69,18 +91,34 @@ void main() {
final cubit = MockRegisterConsultantCubit(); final cubit = MockRegisterConsultantCubit();
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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.state).thenReturn(state);
when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.stream).thenAnswer((_) => Stream.value(state));
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.updateConsultantProfileModel(any)).thenReturn(null); 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();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));

View File

@@ -21,12 +21,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart';
void main() { void main() {
group('StepperBecomeConsultantScreen smoke', () { group('StepperBecomeConsultantScreen smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); 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) { Widget wrap(Widget child) {
@@ -48,11 +54,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -71,11 +88,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -86,7 +114,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); 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 { testWidgets('displays progress bar', (tester) async {
@@ -94,11 +122,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -117,11 +156,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -140,11 +190,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);
@@ -163,11 +224,22 @@ void main() {
final cubit = RegisterConsultantCubit(consultantRepository: repository); final cubit = RegisterConsultantCubit(consultantRepository: repository);
const consultant = ConsultantProfileModel( const consultant = ConsultantProfileModel(
hasMedicalEducation: null, 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, photos: null,
certificates: 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); cubit.emit(state);

View File

@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_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/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/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/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -36,42 +37,99 @@ void main() {
setUp(() { setUp(() {
mockService = MockConsultantService(); mockService = MockConsultantService();
repository = ConsultantRepository(consultantService: mockService); repository = ConsultantRepository(consultantService: mockService);
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); consultantProfile = ConsultantProfileModel.fromJson(
consultationRequest = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); FixtureReader.readAsMap('consultant/consultant_profile_request.json'),
certPhotos = FixtureReader.readAsList('consultant/consultant_cert_photos.json') );
.map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>)) consultationRequest = ConsultationRequestModel.fromJson(
.toList(); 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 { test(
when(() => mockService.consultantRegistration(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))).thenAnswer((_) async {}); '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); verify(
verifyNever(() => mockService.consultantUpdate(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))); () => mockService.consultantRegistration(
}); consultantModel: consultantProfile,
),
).called(1);
verifyNever(
() => mockService.consultantUpdate(
consultantModel: any<ConsultantProfileModel>(
named: 'consultantModel',
),
),
);
},
);
test('registerConsultant delegates to consultantUpdate when update=true', () async { test(
when(() => mockService.consultantUpdate(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))).thenAnswer((_) async {}); '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); verify(
verifyNever(() => mockService.consultantRegistration(consultantModel: any<ConsultantProfileModel>(named: 'consultantModel'))); () =>
}); mockService.consultantUpdate(consultantModel: consultantProfile),
).called(1);
verifyNever(
() => mockService.consultantRegistration(
consultantModel: any<ConsultantProfileModel>(
named: 'consultantModel',
),
),
);
},
);
test('getConsultantRequestStatus returns request from service', () async { 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'); final result = await repository.getConsultantRequestStatus('user-slug');
expect(result, equals(consultationRequest)); 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 { 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'); final result = await repository.getMyRequestStatus('my-slug');
@@ -80,7 +138,9 @@ void main() {
}); });
test('getConsultantCertPhotos returns photos from service', () async { test('getConsultantCertPhotos returns photos from service', () async {
when(() => mockService.getConsultantCertPhotos()).thenAnswer((_) async => certPhotos); when(
() => mockService.getConsultantCertPhotos(),
).thenAnswer((_) async => certPhotos);
final result = await repository.getConsultantCertPhotos(); final result = await repository.getConsultantCertPhotos();
@@ -88,42 +148,92 @@ void main() {
verify(() => mockService.getConsultantCertPhotos()).called(1); verify(() => mockService.getConsultantCertPhotos()).called(1);
}); });
test('storeConsultantCertPhotos returns empty list when no files provided', () async { test('getSbpBanks returns banks from service', () async {
final result = await repository.storeConsultantCertPhotos(files: []); 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); final result = await repository.getSbpBanks();
verifyNever(() => mockService.storeConsultantCertPhotos(photos: any<List<MultipartFile>>(named: 'photos')));
expect(result, banks);
verify(() => mockService.getSbpBanks()).called(1);
}); });
test('storeConsultantCertPhotos uploads prepared files and returns response', () async { test(
final mockFile = MockFile(); 'storeConsultantCertPhotos returns empty list when no files provided',
when(() => mockFile.readAsBytes()).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); () async {
when(() => mockFile.path).thenReturn('/tmp/certificate.png'); final result = await repository.storeConsultantCertPhotos(files: []);
when(() => mockService.storeConsultantCertPhotos(photos: any<List<MultipartFile>>(named: 'photos'))).thenAnswer((_) async => certPhotos);
final result = await repository.storeConsultantCertPhotos(files: [mockFile]); expect(result, isEmpty);
verifyNever(
() => mockService.storeConsultantCertPhotos(
photos: any<List<MultipartFile>>(named: 'photos'),
),
);
},
);
expect(result, certPhotos); test(
final capturedArgument = verify(() => mockService.storeConsultantCertPhotos(photos: captureAny<List<MultipartFile>>(named: 'photos'))).captured.single as List<MultipartFile>; 'storeConsultantCertPhotos uploads prepared files and returns response',
expect(capturedArgument, hasLength(1)); () async {
expect(capturedArgument.first.filename, 'certificate.png'); 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 { 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); 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 { 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); await repository.unHideConsultantCertPhoto(7);
verify(() => mockService.unHideConsultantCertPhoto(payload: {'cert_photo_id': 7})).called(1); verify(
() => mockService.unHideConsultantCertPhoto(
payload: {'cert_photo_id': 7},
),
).called(1);
}); });
}); });
} }

View File

@@ -20,11 +20,14 @@ void main() {
test('getMyRequestStatus - возвращает статус запроса', () async { test('getMyRequestStatus - возвращает статус запроса', () async {
const slug = 'test-consultant'; 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); final expectedRequest = ConsultationRequestModel.fromJson(json);
when(mockConsultantService.getMyRequestStatus(slug: slug)) when(
.thenAnswer((_) async => expectedRequest); mockConsultantService.getMyRequestStatus(slug: slug),
).thenAnswer((_) async => expectedRequest);
final result = await mockConsultantService.getMyRequestStatus(slug: slug); final result = await mockConsultantService.getMyRequestStatus(slug: slug);
@@ -33,25 +36,36 @@ void main() {
verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1); verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1);
}); });
test('getConsultantRequestStatus - возвращает статус запроса консультанта', () async { test(
const slug = 'test-consultant'; 'getConsultantRequestStatus - возвращает статус запроса консультанта',
final json = FixtureReader.readAsMap('consultant/consultation_request.json'); () async {
final expectedRequest = ConsultationRequestModel.fromJson(json); const slug = 'test-consultant';
final json = FixtureReader.readAsMap(
'consultant/consultation_request.json',
);
final expectedRequest = ConsultationRequestModel.fromJson(json);
when(mockConsultantService.getConsultantRequestStatus(slug: slug)) when(
.thenAnswer((_) async => expectedRequest); 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>()); expect(result, isA<ConsultationRequestModel>());
verify(mockConsultantService.getConsultantRequestStatus(slug: slug)).called(1); verify(
}); mockConsultantService.getConsultantRequestStatus(slug: slug),
).called(1);
},
);
test('getConsultantCertPhotos - возвращает фото сертификатов', () async { test('getConsultantCertPhotos - возвращает фото сертификатов', () async {
final expectedPhotos = <ConsultantCertPhotoModel>[]; final expectedPhotos = <ConsultantCertPhotoModel>[];
when(mockConsultantService.getConsultantCertPhotos()) when(
.thenAnswer((_) async => expectedPhotos); mockConsultantService.getConsultantCertPhotos(),
).thenAnswer((_) async => expectedPhotos);
final result = await mockConsultantService.getConsultantCertPhotos(); final result = await mockConsultantService.getConsultantCertPhotos();
@@ -60,16 +74,22 @@ void main() {
}); });
test('consultantRegistration - регистрирует консультанта', () async { 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); final profile = ConsultantProfileModel.fromJson(profileJson);
when(mockConsultantService.consultantRegistration(consultantModel: profile)) when(
.thenAnswer((_) async => Future.value()); 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);
}); });
}); });
} }

View File

@@ -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.',
),
'Такой СНИЛС существует',
);
});
});
}

View File

@@ -0,0 +1,92 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
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',
);
group('PassportDataValidator', () {
test('returns empty map for a valid individual person profile', () {
expect(PassportDataValidator.validate(valid), isEmpty);
});
test('requires empty text fields', () {
final errors = PassportDataValidator.validate(
valid.copyWith(lastName: '', firstName: null),
);
expect(errors['last_name'], 'Укажите фамилию');
expect(errors['first_name'], 'Укажите имя');
});
test('rejects short INN and malformed SNILS', () {
final errors = PassportDataValidator.validate(
valid.copyWith(inn: '123', snils: '12345678900'),
);
expect(errors['inn'], 'Некорректный ИНН');
expect(errors['snils'], 'Некорректный СНИЛС');
});
test('rejects empty INN and SNILS', () {
final errors = PassportDataValidator.validate(
valid.copyWith(inn: '', snils: ''),
);
expect(errors['inn'], 'Укажите ИНН');
expect(errors['snils'], 'Укажите СНИЛС');
});
test('rejects non-calendar dates', () {
final errors = PassportDataValidator.validate(
valid.copyWith(
birthDate: '2020-02-31',
passportIssueDate: '1999-13-32',
),
);
expect(errors['birth_date'], 'Некорректная дата рождения');
expect(errors['passport_issue_date'], 'Некорректная дата выдачи');
});
test('rejects dates in the future', () {
final errors = PassportDataValidator.validate(
valid.copyWith(
birthDate: '2030-01-01',
passportIssueDate: '2030-01-01',
),
now: DateTime(2026, 8, 13),
);
expect(errors['birth_date'], PassportDataValidator.futureDateMessage);
expect(
errors['passport_issue_date'],
PassportDataValidator.futureDateMessage,
);
});
test('accepts today as a valid date', () {
final errors = PassportDataValidator.validate(
valid.copyWith(passportIssueDate: '2026-08-13'),
now: DateTime(2026, 8, 13, 18),
);
expect(errors.containsKey('passport_issue_date'), isFalse);
});
});
}

View File

@@ -1,6 +1,9 @@
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart'; import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -26,8 +29,17 @@ void main() {
late bool? gateResult; late bool? gateResult;
late bool formCalled; late bool formCalled;
late PromoCodeResult? formResult; late PromoCodeResult? formResult;
late PromoCodeModel? formInitialPromo;
late bool sbpCalled; late bool sbpCalled;
late int sbpPlanId; late int sbpPlanId;
late int validateCalls;
late int applyCalls;
late String? lastValidatedCode;
late String? lastAppliedCode;
late PromoValidateResponse validateResponse;
late PromoApplyResponse applyResponse;
late bool applyThrows;
late PromoCodeModel? listAppliedResult;
Future<bool?> mockGate(BuildContext context) async { Future<bool?> mockGate(BuildContext context) async {
gateCalled = true; gateCalled = true;
@@ -37,8 +49,27 @@ void main() {
Future<PromoCodeResult?> mockForm( Future<PromoCodeResult?> mockForm(
BuildContext context, { BuildContext context, {
Future<PromoValidation> Function(String)? onValidate, Future<PromoValidation> Function(String)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) async { }) async {
formCalled = true; formCalled = true;
formInitialPromo = initialPromo;
if (formResult?.hasPromoCode == true &&
!formResult!.alreadyApplied &&
onValidate != null) {
final validation = await onValidate(formResult!.code!);
if (!validation.isValid) {
return null;
}
return PromoCodeResult(
code: formResult!.code,
discountPercent: validation.discountPercent,
discountAmountRub: validation.discountAmountRub,
originalAmountRub: validation.originalAmountRub,
finalAmountRub: validation.finalAmountRub,
);
}
return formResult; return formResult;
} }
@@ -47,31 +78,86 @@ void main() {
sbpPlanId = planId; sbpPlanId = planId;
} }
Future<PromoValidateResponse> mockValidate({
required int planId,
required String promoCode,
}) async {
validateCalls++;
lastValidatedCode = promoCode;
return validateResponse;
}
Future<PromoApplyResponse> mockApply({
required int planId,
required String promoCode,
}) async {
applyCalls++;
lastAppliedCode = promoCode;
if (applyThrows) {
throw Exception('network');
}
return applyResponse;
}
Future<PromoCodeModel?> mockListApplied(int planId) async {
return listAppliedResult;
}
Future<bool> mockRemove(int planId) async {
return true;
}
setUp(() { setUp(() {
gateCalled = false; gateCalled = false;
gateResult = null; gateResult = null;
formCalled = false; formCalled = false;
formResult = null; formResult = null;
formInitialPromo = null;
sbpCalled = false; sbpCalled = false;
sbpPlanId = 0; sbpPlanId = 0;
validateCalls = 0;
applyCalls = 0;
lastValidatedCode = null;
lastAppliedCode = null;
listAppliedResult = null;
validateResponse = const PromoValidateResponse(
valid: true,
promoCode: 'TEST123',
discountPercent: 5,
discountAmountRub: 60,
originalAmountRub: 1200,
finalAmountRub: 1140,
);
applyResponse = const PromoApplyResponse(success: true);
applyThrows = false;
}); });
testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { Future<void> runFlow(WidgetTester tester) {
gateResult = false; return showConsultationPaymentFlow(
await tester.pumpWidget(createTestApp());
await showConsultationPaymentFlow(
tester.element(find.byType(SizedBox)), tester.element(find.byType(SizedBox)),
planId: 42, planId: 42,
showGate: mockGate, showGate: mockGate,
showForm: mockForm, showForm: mockForm,
showSbp: mockSbp, showSbp: mockSbp,
validatePromo: mockValidate,
applyPromo: mockApply,
listApplied: mockListApplied,
removePromo: mockRemove,
); );
}
testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async {
gateResult = false;
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(gateCalled, isTrue); expect(gateCalled, isTrue);
expect(formCalled, isFalse); expect(formCalled, isFalse);
expect(sbpCalled, isTrue); expect(sbpCalled, isTrue);
expect(sbpPlanId, 42); expect(sbpPlanId, 42);
expect(validateCalls, 0);
expect(applyCalls, 0);
}); });
testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async { testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async {
@@ -79,18 +165,13 @@ void main() {
formResult = const PromoCodeResult(); formResult = const PromoCodeResult();
await tester.pumpWidget(createTestApp()); await tester.pumpWidget(createTestApp());
await showConsultationPaymentFlow( await runFlow(tester);
tester.element(find.byType(SizedBox)),
planId: 42,
showGate: mockGate,
showForm: mockForm,
showSbp: mockSbp,
);
expect(gateCalled, isTrue); expect(gateCalled, isTrue);
expect(formCalled, isTrue); expect(formCalled, isTrue);
expect(sbpCalled, isTrue); expect(sbpCalled, isTrue);
expect(sbpPlanId, 42); expect(sbpPlanId, 42);
expect(applyCalls, 0);
}); });
testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async { testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async {
@@ -98,13 +179,7 @@ void main() {
formResult = null; formResult = null;
await tester.pumpWidget(createTestApp()); await tester.pumpWidget(createTestApp());
await showConsultationPaymentFlow( await runFlow(tester);
tester.element(find.byType(SizedBox)),
planId: 42,
showGate: mockGate,
showForm: mockForm,
showSbp: mockSbp,
);
expect(gateCalled, isTrue); expect(gateCalled, isTrue);
expect(formCalled, isTrue); expect(formCalled, isTrue);
@@ -115,17 +190,121 @@ void main() {
gateResult = null; gateResult = null;
await tester.pumpWidget(createTestApp()); await tester.pumpWidget(createTestApp());
await showConsultationPaymentFlow( await runFlow(tester);
tester.element(find.byType(SizedBox)),
planId: 42,
showGate: mockGate,
showForm: mockForm,
showSbp: mockSbp,
);
expect(gateCalled, isTrue); expect(gateCalled, isTrue);
expect(formCalled, isFalse); expect(formCalled, isFalse);
expect(sbpCalled, isFalse); expect(sbpCalled, isFalse);
}); });
testWidgets('Да → код → validate + apply → SbpBanks', (tester) async {
gateResult = true;
formResult = const PromoCodeResult(code: 'TEST123');
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(validateCalls, 1);
expect(lastValidatedCode, 'TEST123');
expect(applyCalls, 1);
expect(lastAppliedCode, 'TEST123');
expect(sbpCalled, isTrue);
expect(sbpPlanId, 42);
});
testWidgets('Да → код → apply fail → Sbp не вызывается', (tester) async {
gateResult = true;
formResult = const PromoCodeResult(code: 'BAD');
applyResponse = const PromoApplyResponse(
success: false,
error: 'Промокод уже использован',
);
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(applyCalls, 1);
expect(sbpCalled, isFalse);
});
testWidgets('applied rehydrate → Continue → SBP без apply и без гейта', (tester) async {
listAppliedResult = const PromoCodeModel(
id: 1,
chosenPlanId: 42,
promoCode: 'SAVE5',
discountPercent: 5,
discountAmountRub: 60,
state: 'applied',
);
formResult = const PromoCodeResult(
code: 'SAVE5',
discountPercent: 5,
discountAmountRub: 60,
alreadyApplied: true,
);
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(gateCalled, isFalse);
expect(formCalled, isTrue);
expect(formInitialPromo?.promoCode, 'SAVE5');
expect(applyCalls, 0);
expect(sbpCalled, isTrue);
});
testWidgets('list пустой → идёт через гейт', (tester) async {
listAppliedResult = null;
gateResult = false;
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(gateCalled, isTrue);
expect(formCalled, isFalse);
expect(sbpCalled, isTrue);
});
testWidgets('rehydrate → Remove → новый код → apply → SBP', (tester) async {
listAppliedResult = const PromoCodeModel(
id: 1,
chosenPlanId: 42,
promoCode: 'OLD',
discountPercent: 5,
discountAmountRub: 60,
state: 'applied',
);
formResult = const PromoCodeResult(code: 'NEW123');
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(gateCalled, isFalse);
expect(formCalled, isTrue);
expect(validateCalls, 1);
expect(applyCalls, 1);
expect(lastAppliedCode, 'NEW123');
expect(sbpCalled, isTrue);
});
testWidgets('rehydrate → Remove → Skip → SBP без apply', (tester) async {
listAppliedResult = const PromoCodeModel(
id: 1,
chosenPlanId: 42,
promoCode: 'OLD',
discountPercent: 5,
discountAmountRub: 60,
state: 'applied',
);
formResult = const PromoCodeResult();
await tester.pumpWidget(createTestApp());
await runFlow(tester);
expect(gateCalled, isFalse);
expect(formCalled, isTrue);
expect(applyCalls, 0);
expect(sbpCalled, isTrue);
});
}); });
} }

View File

@@ -0,0 +1,108 @@
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PromoValidateResponse', () {
test('parses valid preview', () {
final response = PromoValidateResponse.fromJson({
'valid': true,
'promo_code': 'PROMO_100117_A',
'discount_percent': 5,
'discount_amount_rub': 60.0,
'original_amount_rub': 1200.0,
'final_amount_rub': 1140.0,
});
expect(response.valid, isTrue);
expect(response.promoCode, 'PROMO_100117_A');
expect(response.discountPercent, 5);
expect(response.finalAmountRub, 1140.0);
expect(response.error, isNull);
});
test('parses invalid preview with HTTP 200 body', () {
final response = PromoValidateResponse.fromJson({
'valid': false,
'error': 'Промокод «UNKNOWN» не найден в системе.',
});
expect(response.valid, isFalse);
expect(response.error, contains('не найден'));
});
});
group('PromoApplyResponse', () {
test('parses success with nested promo_code', () {
final response = PromoApplyResponse.fromJson({
'success': true,
'promo_code': {
'id': 1,
'chosen_plan_id': 42,
'promo_code': 'PROMO_100117_A',
'discount_percent': 5,
'discount_amount_rub': 60.0,
'state': 'applied',
'applied_at': '2024-01-15T10:30:00+00:00',
'confirmed_at': null,
'cancelled_at': null,
},
'discount_percent': 5,
'discount_amount_rub': 60.0,
'original_amount_rub': 1200.0,
'final_amount_rub': 1140.0,
});
expect(response.success, isTrue);
expect(response.promoCode, isA<PromoCodeModel>());
expect(response.promoCode!.promoCode, 'PROMO_100117_A');
expect(response.promoCode!.state, 'applied');
expect(response.finalAmountRub, 1140.0);
});
test('parses failure body', () {
final response = PromoApplyResponse.fromJson({
'success': false,
'error': 'Промокод «X» уже был использован вами ранее.',
});
expect(response.success, isFalse);
expect(response.error, contains('уже был использован'));
expect(response.promoCode, isNull);
});
});
group('PromoAppliedListResponse', () {
test('parses promo_codes list', () {
final response = PromoAppliedListResponse.fromJson({
'promo_codes': [
{
'id': 2,
'chosen_plan_id': 42,
'promo_code': 'PROMO_LIST_B',
'discount_percent': 3,
'discount_amount_rub': 36.0,
'state': 'applied',
'applied_at': '2024-01-15T10:30:00+00:00',
'confirmed_at': null,
'cancelled_at': null,
},
],
});
expect(response.promoCodes, hasLength(1));
expect(response.promoCodes.first.promoCode, 'PROMO_LIST_B');
expect(response.promoCodes.first.chosenPlanId, 42);
});
test('parses empty list', () {
final response = PromoAppliedListResponse.fromJson({
'promo_codes': <dynamic>[],
});
expect(response.promoCodes, isEmpty);
});
});
}

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -11,6 +12,8 @@ void main() {
group('ItvioPromoCodeModal', () { group('ItvioPromoCodeModal', () {
Widget createTestWidget({ Widget createTestWidget({
Future<PromoValidation> Function(String)? onValidate, Future<PromoValidation> Function(String)? onValidate,
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
}) { }) {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
@@ -26,6 +29,8 @@ void main() {
onPressed: () => ItvioPromoCodeModal.show( onPressed: () => ItvioPromoCodeModal.show(
context, context,
onValidate: onValidate, onValidate: onValidate,
onRemove: onRemove,
initialPromo: initialPromo,
), ),
child: const Text('Open'), child: const Text('Open'),
), ),
@@ -88,14 +93,84 @@ void main() {
expect(find.text('Есть промокод?'), findsNothing); expect(find.text('Есть промокод?'), findsNothing);
}); });
testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async { testWidgets('initialPromo показывает applied UI', (tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(
createTestWidget(
initialPromo: const PromoCodeModel(
id: 1,
chosenPlanId: 42,
promoCode: 'SAVE5',
discountPercent: 5,
discountAmountRub: 60,
state: 'applied',
),
onRemove: () async => true,
),
);
await tester.tap(find.text('Open')); await tester.tap(find.text('Open'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Промокод применён!'), findsOneWidget);
expect(find.text('Продолжить'), findsOneWidget);
expect(find.text('Убрать промокод'), findsOneWidget);
expect(find.text('Применить'), findsNothing);
});
testWidgets('Убрать промокод переводит в input', (tester) async {
var removeCalled = false;
await tester.pumpWidget(
createTestWidget(
initialPromo: const PromoCodeModel(
id: 1,
chosenPlanId: 42,
promoCode: 'SAVE5',
discountPercent: 5,
discountAmountRub: 60,
state: 'applied',
),
onRemove: () async {
removeCalled = true;
return true;
},
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Убрать промокод'));
await tester.pumpAndSettle();
expect(removeCalled, isTrue);
expect(find.text('Есть промокод?'), findsOneWidget);
expect(find.text('Применить'), findsOneWidget);
expect(find.text('Пропустить'), findsOneWidget);
expect(find.text('Промокод применён!'), findsNothing); expect(find.text('Промокод применён!'), findsNothing);
expect(find.text('Продолжить'), findsNothing); });
expect(find.text('Убрать промокод'), findsNothing);
testWidgets('Применить с успешным onValidate закрывает шторку', (tester) async {
String? validatedCode;
await tester.pumpWidget(
createTestWidget(
onValidate: (code) async {
validatedCode = code;
return const PromoValidation.success(
discountPercent: 5,
discountAmountRub: 60,
originalAmountRub: 1200,
finalAmountRub: 1140,
);
},
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'save5');
await tester.tap(find.text('Применить'));
await tester.pumpAndSettle();
expect(validatedCode, 'SAVE5');
expect(find.text('Есть промокод?'), findsNothing);
}); });
}); });
} }

View File

@@ -47,13 +47,77 @@ class _FakeHttpClientRequest implements HttpClientRequest {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientResponse { class _FakeHttpClientResponse extends Stream<List<int>>
implements HttpClientResponse {
// 1x1 transparent PNG. // 1x1 transparent PNG.
static final List<int> _png = <int>[ 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, 0x89,
0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x50,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x4E,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, 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 @override
@@ -63,11 +127,22 @@ class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientRes
int get contentLength => _png.length; int get contentLength => _png.length;
@override @override
HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; HttpClientResponseCompressionState get compressionState =>
HttpClientResponseCompressionState.notCompressed;
@override @override
StreamSubscription<List<int>> listen(void Function(List<int>)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) { StreamSubscription<List<int>> listen(
return Stream<List<int>>.fromIterable([_png]).listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); void Function(List<int>)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return Stream<List<int>>.fromIterable([_png]).listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
} }
@override @override
@@ -78,12 +153,18 @@ class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientRes
void main() { void main() {
group('ProfileBlogPostWidget smoke', () { group('ProfileBlogPostWidget smoke', () {
setUpAll(() async { 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 { setUp(() async {
await sl.reset(); await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
}); });
const profile = ProfileModel( const profile = ProfileModel(
@@ -160,7 +241,13 @@ void main() {
testWidgets('renders gallery and video media content', (tester) async { testWidgets('renders gallery and video media content', (tester) async {
final post = FeedFixtures.feedBlogPostGalleryAndVideo(); final post = FeedFixtures.feedBlogPostGalleryAndVideo();
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); final loadedState = ProfileState.loaded(
profile: profile,
profileDetails: details,
blogPosts: [post],
isMyProfile: true,
certPhotos: [],
);
final cubit = MockProfileCubit(); final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState); when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty()); when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
@@ -174,7 +261,13 @@ void main() {
testWidgets('renders gallery-only media content', (tester) async { testWidgets('renders gallery-only media content', (tester) async {
final post = FeedFixtures.feedBlogPostWithGallery(); final post = FeedFixtures.feedBlogPostWithGallery();
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); final loadedState = ProfileState.loaded(
profile: profile,
profileDetails: details,
blogPosts: [post],
isMyProfile: true,
certPhotos: [],
);
final cubit = MockProfileCubit(); final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState); when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty()); when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
@@ -188,7 +281,13 @@ void main() {
testWidgets('renders video-only media content', (tester) async { testWidgets('renders video-only media content', (tester) async {
final post = FeedFixtures.feedBlogPostVideoOnly(); final post = FeedFixtures.feedBlogPostVideoOnly();
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); final loadedState = ProfileState.loaded(
profile: profile,
profileDetails: details,
blogPosts: [post],
isMyProfile: true,
certPhotos: [],
);
final cubit = MockProfileCubit(); final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState); when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty()); when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
@@ -199,5 +298,84 @@ void main() {
expect(find.byType(BlogPostMediaContent), findsOneWidget); expect(find.byType(BlogPostMediaContent), findsOneWidget);
expect(find.text('Видео'), findsOneWidget); expect(find.text('Видео'), findsOneWidget);
}); });
testWidgets('cover-only posts with the same image use unique hero tags', (
tester,
) async {
const sharedImage = 'https://example.com/same-cover.jpg';
final posts = [
FeedFixtures.feedBlogPostModel().copyWith(
id: 11,
title: 'Первый',
description: 'Текст',
image: sharedImage,
images: const [],
videoUrl: '',
videoEmbedUrl: null,
),
FeedFixtures.feedBlogPostModel().copyWith(
id: 22,
title: 'Второй',
description: 'Текст',
image: sharedImage,
images: const [],
videoUrl: '',
videoEmbedUrl: null,
),
];
final loadedState = ProfileState.loaded(
profile: profile,
profileDetails: details,
blogPosts: posts,
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 HttpOverrides.runZoned(() async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(
body: MediaQuery(
data: const MediaQueryData(size: Size(800, 1600)),
child: SingleChildScrollView(
child: Column(
children: [
ProfileBlogPostWidget(index: 0, profileCubit: cubit),
ProfileBlogPostWidget(index: 1, profileCubit: cubit),
],
),
),
),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 200));
}, createHttpClient: (_) => _FakeHttpClient());
final tags = tester
.widgetList<Hero>(find.byType(Hero))
.map((hero) => hero.tag)
.toList();
expect(
tags,
containsAll([
'profile-blog-post-11-cover',
'profile-blog-post-22-cover',
]),
);
expect(tags.toSet().length, tags.length);
});
}); });
} }

View File

@@ -1,7 +1,6 @@
{ {
"title": "Complete Test Title", "title": "Complete Test Title",
"description_md": "Complete Test Description", "description_md": "Complete Test Description",
"video_url": "https://youtube.com/watch?v=complete", "video_url": "https://rutube.ru/video/12345/",
"image_name": null "image_name": null
} }

View File

@@ -2,8 +2,7 @@
"id": 1, "id": 1,
"title": "Updated Title", "title": "Updated Title",
"description_md": "Updated Description", "description_md": "Updated Description",
"video_url": "https://youtube.com/watch?v=updated", "video_url": "https://vk.com/video-1_1",
"old_image_url": "original_image.jpg", "existing_media_ids": [2, 1],
"image_name": null "image_name": null
} }

View File

@@ -20,6 +20,26 @@
"title": "Психология питания", "title": "Психология питания",
"date": "2023-06-05" "date": "2023-06-05"
} }
] ],
"legal_type": "individualPerson",
"last_name": "Иванов",
"first_name": "Иван",
"middle_name": "Иванович",
"inn": "123456789012",
"snils": "123-456-789 00",
"passport_series": "1234",
"passport_number": "567890",
"passport_issue_date": "2020-01-15",
"passport_issuer": "ОВД Пресненского района г. Москвы",
"passport_issuer_code": "772-053",
"birth_date": "1990-05-20",
"birth_place": "г. Москва",
"registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
"residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1",
"citizenship": "RU",
"tax_resident_us": false,
"foreign_tax_resident": false,
"sbp_phone": "+79991234567",
"agreement_accepted": true,
"frdo_agreement_accepted": true
} }

View File

@@ -23,6 +23,31 @@
"personal_site_link": "https://consultant.eatvio.app", "personal_site_link": "https://consultant.eatvio.app",
"request_state": "approved" "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": [ "cert_photos": [
{ {
"url": "https://cdn.eatvio.app/certificates/visible.jpg" "url": "https://cdn.eatvio.app/certificates/visible.jpg"
@@ -40,4 +65,3 @@
] ]
} }
} }

16
test/fixtures/consultant/sbp_banks.json vendored Normal file
View File

@@ -0,0 +1,16 @@
[
{
"bank_member_id": "100000000111",
"bank_name": "Сбербанк",
"short_name": "Сбер",
"logo_url": "https://example.com/sber.png",
"bic": "044525225"
},
{
"bank_member_id": "100000000004",
"bank_name": "Т-Банк",
"short_name": "ТБанк",
"logo_url": "https://example.com/tbank.png",
"bic": "044525974"
}
]

View File

@@ -4,12 +4,12 @@ import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
class FixtureBlogPostRepository extends BlogPostRepository { class FixtureBlogPostRepository extends BlogPostRepository {
FixtureBlogPostRepository() FixtureBlogPostRepository() : super(blogPostService: _NoopBlogPostService());
: super(blogPostService: _NoopBlogPostService());
final List<BlogPostCreatePayload> createPayloads = <BlogPostCreatePayload>[]; final List<BlogPostCreatePayload> createPayloads = <BlogPostCreatePayload>[];
final List<BlogPostUpdatePayload> updatePayloads = <BlogPostUpdatePayload>[]; final List<BlogPostUpdatePayload> updatePayloads = <BlogPostUpdatePayload>[];
final List<int> deleteCalls = <int>[]; final List<int> deleteCalls = <int>[];
final List<({int postId, int mediaId})> deleteMediaCalls = <({int postId, int mediaId})>[];
Duration createDelay = Duration.zero; Duration createDelay = Duration.zero;
Duration updateDelay = Duration.zero; Duration updateDelay = Duration.zero;
@@ -18,15 +18,21 @@ class FixtureBlogPostRepository extends BlogPostRepository {
Object? createError; Object? createError;
Object? updateError; Object? updateError;
Object? deleteError; Object? deleteError;
Object? deleteMediaError;
@override @override
Future<void> createBlogPost(String? title, String? videoUrl, String? description, XFile? image) async { Future<void> createBlogPost({
String? title,
String? videoUrl,
String? description,
List<XFile> images = const [],
}) async {
createPayloads.add( createPayloads.add(
BlogPostCreatePayload( BlogPostCreatePayload(
title: title, title: title,
description: description, description: description,
videoUrl: videoUrl, videoUrl: videoUrl,
imageName: image?.name, imageName: images.isEmpty ? null : images.first.name,
), ),
); );
if (createDelay > Duration.zero) { if (createDelay > Duration.zero) {
@@ -38,15 +44,26 @@ class FixtureBlogPostRepository extends BlogPostRepository {
} }
@override @override
Future<void> updateBlogPost(int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl) async { Future<void> updateBlogPost({
required int id,
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
List<int> mediaIdsToDelete = const [],
}) async {
for (final mediaId in mediaIdsToDelete) {
deleteMediaCalls.add((postId: id, mediaId: mediaId));
}
updatePayloads.add( updatePayloads.add(
BlogPostUpdatePayload( BlogPostUpdatePayload(
id: id, id: id,
title: title, title: title,
description: description, description: description,
videoUrl: videoUrl, videoUrl: videoUrl,
imageName: image?.name, imageName: newImages.isEmpty ? null : newImages.first.name,
oldImageUrl: oldImageUrl, existingMediaIds: existingMediaIds,
), ),
); );
if (updateDelay > Duration.zero) { if (updateDelay > Duration.zero) {
@@ -97,7 +114,7 @@ class BlogPostUpdatePayload {
required this.description, required this.description,
required this.videoUrl, required this.videoUrl,
required this.imageName, required this.imageName,
required this.oldImageUrl, required this.existingMediaIds,
}); });
final int id; final int id;
@@ -105,15 +122,15 @@ class BlogPostUpdatePayload {
final String? description; final String? description;
final String? videoUrl; final String? videoUrl;
final String? imageName; final String? imageName;
final String? oldImageUrl; final List<int> existingMediaIds;
Map<String, dynamic> toJson() => <String, dynamic>{ Map<String, dynamic> toJson() => <String, dynamic>{
'id': id, 'id': id,
'title': title, 'title': title,
'description_md': description, 'description_md': description,
'video_url': videoUrl, 'video_url': videoUrl,
'existing_media_ids': existingMediaIds,
'image_name': imageName, 'image_name': imageName,
'old_image_url': oldImageUrl,
}; };
} }
@@ -128,6 +145,11 @@ class _NoopBlogPostService implements BlogPostService {
throw UnimplementedError('Fixture overrides repository methods directly.'); throw UnimplementedError('Fixture overrides repository methods directly.');
} }
@override
Future<void> deleteBlogPostMedia({required int postId, required int mediaId}) {
throw UnimplementedError('Fixture overrides repository methods directly.');
}
@override @override
Future<void> updateBlogPost({required int id, required FormData formData}) { Future<void> updateBlogPost({required int id, required FormData formData}) {
throw UnimplementedError('Fixture overrides repository methods directly.'); throw UnimplementedError('Fixture overrides repository methods directly.');
@@ -141,4 +163,3 @@ class FeedReloadProbe {
loadRequests += 1; loadRequests += 1;
} }
} }